From 92d6141cdf61f9943dfc8e2e46870e54e46d8641 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:19:15 +0000 Subject: [PATCH] Sync generated provider output --- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .pi/skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- 20 files changed, 10570 insertions(+), 3850 deletions(-) diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.gemini/skills/impeccable/scripts/live/ui-core.mjs b/.gemini/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.gemini/skills/impeccable/scripts/live/ui-core.mjs +++ b/.gemini/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.github/skills/impeccable/scripts/live/ui-core.mjs b/.github/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.github/skills/impeccable/scripts/live/ui-core.mjs +++ b/.github/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.kiro/skills/impeccable/scripts/live/ui-core.mjs b/.kiro/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.kiro/skills/impeccable/scripts/live/ui-core.mjs +++ b/.kiro/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.opencode/skills/impeccable/scripts/live/ui-core.mjs b/.opencode/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.opencode/skills/impeccable/scripts/live/ui-core.mjs +++ b/.opencode/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.pi/skills/impeccable/scripts/live/ui-core.mjs b/.pi/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.pi/skills/impeccable/scripts/live/ui-core.mjs +++ b/.pi/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.qoder/skills/impeccable/scripts/live/ui-core.mjs b/.qoder/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.qoder/skills/impeccable/scripts/live/ui-core.mjs +++ b/.qoder/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.rovodev/skills/impeccable/scripts/live/ui-core.mjs b/.rovodev/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.rovodev/skills/impeccable/scripts/live/ui-core.mjs +++ b/.rovodev/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.trae-cn/skills/impeccable/scripts/live/ui-core.mjs b/.trae-cn/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.trae-cn/skills/impeccable/scripts/live/ui-core.mjs +++ b/.trae-cn/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/.trae/skills/impeccable/scripts/live/ui-core.mjs b/.trae/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.trae/skills/impeccable/scripts/live/ui-core.mjs +++ b/.trae/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], }, diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -57,6 +57,7 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor'; const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000; const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({ prefix: PREFIX, @@ -98,7 +99,7 @@ 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: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] }, { key: 'action-picker', ids: [PREFIX + '-picker'] }, { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, @@ -131,6 +132,8 @@ let currentPreviewFile = null; let currentPreviewMode = null; let recoveryWaitingForAnchor = false; + let pickedAnchorSnapshot = null; + let pendingVariantAnchorRetryObserver = null; let pendingAcceptedSession = null; let variantObserver = null; let variantSelectionInFlight = false; @@ -283,31 +286,53 @@ uiAppend(tooltipEl); } + function shouldShowHighlightTagTooltip() { + // Configure/edit carry the tag in the bar selection pill — keep only the outline. + return state !== 'CONFIGURING' && state !== 'EDITING'; + } + + function hideHighlightTagTooltip() { + if (!tooltipEl) return; + tooltipEl.style.opacity = '0'; + tooltipEl.style.display = 'none'; + } + function showHighlight(el) { if (!el || !highlightEl) return; if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return; const r = el.getBoundingClientRect(); const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; - const tipTop = r.top - 20; - const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; - const tipX = Math.max(4, r.left) + 'px'; - tooltipEl.textContent = desc(el); + const showTagTooltip = shouldShowHighlightTagTooltip(); const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; if (hiWasHidden) { // Snap to first target without animating from (0,0), then fade in. highlightEl.style.transition = 'none'; Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); - tooltipEl.style.transition = 'none'; - Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); void highlightEl.offsetWidth; highlightEl.style.transition = HIGHLIGHT_TRANSITION; highlightEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + } + + if (!showTagTooltip) { + hideHighlightTagTooltip(); + return; + } + + const tipTop = r.top - 20; + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + if (hiWasHidden) { + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void tooltipEl.offsetWidth; tooltipEl.style.transition = TOOLTIP_TRANSITION; tooltipEl.style.opacity = '1'; } else { - Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); } } @@ -1010,8 +1035,8 @@ boxShadow: BP.shadow, transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, fontFamily: FONT, fontSize: '13px', color: BP.text, - padding: '6px', - maxWidth: '520px', minWidth: '320px', + padding: '5px', + maxWidth: '560px', minWidth: '340px', }); uiAppend(barEl); defangOutsideHandlers(barEl); @@ -1019,14 +1044,29 @@ function positionBar() { if (!barEl) return; - const anchor = resolveBarAnchor(); - if (!anchor) return; - const r = anchor.getBoundingClientRect(); const barH = barEl.offsetHeight || 44; const barW = barEl.offsetWidth || 380; const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room const GAP = 8; + // Recovery pins to document.body when the picked element is off-screen or + // missing. Center the generating bar above the global bar instead of + // stacking a duplicate toast in the same slot. + if (recoveryWaitingForAnchor) { + const barRect = globalBarEl?.getBoundingClientRect(); + const reserve = barRect && barRect.height > 0 + ? Math.max(GLOBAL_BAR_RESERVE, window.innerHeight - barRect.top + 12) + : GLOBAL_BAR_RESERVE; + const top = window.innerHeight - barH - reserve; + const left = Math.max(GAP, (window.innerWidth - barW) / 2); + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + return; + } + + const anchor = resolveBarAnchor(); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + // Prefer below the element; fall back to above; if neither fits (element // taller than viewport), pin to a stable viewport anchor so the bar // doesn't teleport between top and bottom as the user scrolls. @@ -1054,8 +1094,14 @@ if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') { + if (recoveryWaitingForAnchor) dismissToast(); + barEl.appendChild(buildGeneratingRow()); + } else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } barEl.style.display = 'block'; positionBar(); requestAnimationFrame(() => { @@ -1075,6 +1121,7 @@ setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + hideConfigureBarTooltip(); if (state === 'EDITING') restoreInlineEditDrafts(); disableInlineEdit(); } @@ -1083,36 +1130,472 @@ 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; - barEl.style.border = '1px solid ' + BP.border; - barEl.style.boxShadow = BP.shadow; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); if (configureKind === 'insert') syncInsertCreateButton(); - } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); - else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); - else if (mode === 'saving') barEl.appendChild(buildSavingRow()); - else if (mode === 'confirmed') { - barEl.appendChild(buildConfirmedRow()); - barEl.style.background = 'oklch(95% 0.05 145)'; - barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + applyConfigureBarChrome(); + } else { + restorePickerBarChrome(); + if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); + else if (mode === 'confirmed') { + barEl.appendChild(buildConfirmedRow()); + barEl.style.background = 'oklch(95% 0.05 145)'; + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; + } } syncPageChatFocus('update-bar-content'); } - // Configure row + // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + + const CONFIGURE_BAR_H = '36px'; + // Compact selection pill + 7px inset balances vertical centering in the 36px bar. + const CONFIGURE_BAR_INSET = '7px'; + const CONFIGURE_PILL_RADIUS = '7px'; + const CONFIGURE_SELECTION_PILL_BORDER = '1px solid oklch(70% 0.12 188)'; + const CONFIGURE_SELECTION_PILL_PAD = '1px 4px'; + const CONFIGURE_ROW_FONT_SIZE = '12px'; + const CONFIGURE_ROW_TRACK_H = '18px'; + const CONFIGURE_PILL_PAD_Y = '3px'; + const CONFIGURE_BAR_SURFACE = 'oklch(15% 0.008 95)'; + const CONFIGURE_PILL_TEXT = 'oklch(94% 0.02 82)'; + const ICON_CONFIGURE_SUBMIT = + ''; + + function applyConfigureBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'true'; + barEl.style.padding = '0'; + barEl.style.background = CONFIGURE_BAR_SURFACE; + barEl.style.overflow = 'hidden'; + syncConfigureInputChrome(); + } + + function restorePickerBarChrome() { + if (!barEl) return; + barEl.dataset.configureSurface = 'false'; + barEl.removeAttribute('data-input-focused'); + barEl.removeAttribute('data-voice-listening'); + barEl.style.padding = '5px'; + barEl.style.background = BP.surface; + barEl.style.overflow = ''; + barEl.style.border = '1px solid ' + BP.border; + barEl.style.borderColor = BP.border; + barEl.style.boxShadow = BP.shadow; + } function syncConfigureInputChrome() { - const wrap = uiGetById(PREFIX + '-configure-input-wrap'); - const input = uiGetById(PREFIX + '-input'); - if (!wrap || !input) return; + const input = uiGetById(PREFIX + '-input') || uiGetById(PREFIX + '-insert-input'); + const surface = barEl?.dataset.configureSurface === 'true' ? barEl : null; + if (!surface || !input) return; 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') + const listening = voiceListening && voiceCtx?.mode === 'configure'; + surface.dataset.inputFocused = focused ? 'true' : 'false'; + surface.dataset.voiceListening = listening ? 'true' : 'false'; + surface.style.borderColor = listening ? BP.patinaSoft - : (focused ? BP.accentSoft : BP.hairline); + : (focused ? BP.accentSoft : BP.border); + surface.style.boxShadow = BP.shadow; + } + + function configureBarPalette() { + return BP || barPaletteForTheme(detectPageTheme()); + } + + function configureRowTextMetrics(extra = {}) { + return { + fontFamily: FONT, + fontSize: CONFIGURE_ROW_FONT_SIZE, + fontWeight: '500', + lineHeight: CONFIGURE_ROW_TRACK_H, + ...extra, + }; + } + + function configureInputFieldStyle(extra = {}) { + return { + flex: '1', minWidth: '0', width: '100%', + padding: '0', margin: '0', + border: 'none', background: 'transparent', + boxSizing: 'border-box', + height: CONFIGURE_ROW_TRACK_H, + color: CONFIGURE_PILL_TEXT, + caretColor: CONFIGURE_PILL_TEXT, + outline: 'none', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInputShellStyle() { + return { + display: 'flex', alignItems: 'center', gap: '6px', + flex: '1', minWidth: '0', height: '100%', + padding: '0 6px 0 ' + CONFIGURE_BAR_INSET, + }; + } + + function configureSelectionPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', flexShrink: '0', + padding: CONFIGURE_SELECTION_PILL_PAD, + boxSizing: 'border-box', + border: CONFIGURE_SELECTION_PILL_BORDER, + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.patina, + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.01em', + }), + ...extra, + }; + } + + function configureModifierPillStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: 'auto', minHeight: CONFIGURE_ROW_TRACK_H, + padding: CONFIGURE_PILL_PAD_Y + ' 8px', flexShrink: '0', + boxSizing: 'border-box', + border: '1px solid transparent', + borderRadius: CONFIGURE_PILL_RADIUS, + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease, border-color 0.15s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function configureInlineControlStyle(extra = {}) { + const P = configureBarPalette(); + return { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: '2px', height: CONFIGURE_ROW_TRACK_H, flexShrink: '0', + padding: '0', margin: '0', + boxSizing: 'border-box', + border: 'none', borderRadius: '0', + background: 'transparent', + color: P.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + whiteSpace: 'nowrap', + ...configureRowTextMetrics(), + ...extra, + }; + } + + function bindConfigureInlineControlHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + btn.style.color = configureBarPalette().textDim; + }); + } + + function bindConfigureModifierPillHover(btn, controlsLocked) { + btn.addEventListener('mouseenter', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.text; + btn.style.background = P.toggleActive; + }); + btn.addEventListener('mouseleave', () => { + if (controlsLocked) return; + const P = configureBarPalette(); + btn.style.color = P.textDim; + btn.style.background = 'transparent'; + }); + } + + let configureBarTooltipEl = null; + + function ensureConfigureBarTooltip() { + if (configureBarTooltipEl) return configureBarTooltipEl; + const P = configureBarPalette(); + configureBarTooltipEl = el('div', { + position: 'fixed', + display: 'none', + zIndex: String(Z.bar + 7), + pointerEvents: 'none', + maxWidth: 'min(360px, calc(100vw - 16px))', + padding: '6px 9px', + borderRadius: '7px', + background: P.chatSurface, + border: '1px solid ' + P.hairline, + boxShadow: P.shadow, + color: P.text, + fontFamily: FONT, + fontSize: '11px', + fontWeight: '500', + lineHeight: '1.35', + letterSpacing: '0.01em', + whiteSpace: 'normal', + wordBreak: 'break-word', + }); + configureBarTooltipEl.id = PREFIX + '-configure-bar-tooltip'; + uiAppend(configureBarTooltipEl); + return configureBarTooltipEl; + } + + function showConfigureBarTooltip(anchor, message) { + if (!anchor || !message) return; + const tip = ensureConfigureBarTooltip(); + tip.textContent = message; + tip.style.transition = 'none'; + tip.style.display = 'block'; + tip.style.opacity = '1'; + const r = anchor.getBoundingClientRect(); + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2)); + const top = Math.max(8, r.top - tipH - 8); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + } + + function hideConfigureBarTooltip() { + if (!configureBarTooltipEl) return; + configureBarTooltipEl.style.display = 'none'; + configureBarTooltipEl.style.opacity = '0'; + } + + function selectionTagLabel(el) { + if (!el) return ''; + if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return 'slot'; + return el.tagName.toLowerCase(); + } + + function elementPath(el, maxDepth = 8) { + if (!el) return ''; + const parts = []; + let node = el; + while (node && node.nodeType === 1 && node !== document.body) { + let part = node.tagName.toLowerCase(); + if (node.id) part += '#' + node.id; + else if (node.classList?.length) part += '.' + [...node.classList].slice(0, 2).join('.'); + parts.unshift(part); + node = node.parentElement; + if (parts.length >= maxDepth) break; + } + return parts.join(' \u203a '); + } + + function variantCountTooltipText(count) { + const n = Number(count) || selectedCount; + const word = n === 1 ? 'variant' : 'variants'; + return 'Click to change \u00b7 ' + n + ' ' + word; + } + + function removeConfigureSelection() { + hideConfigureBarTooltip(); + if (configureKind === 'insert') { + cancelInsertConfigure(); + return; + } + selectedElement = null; + exitConfigureToPicking('selection-pill-remove', { clearHover: true }); + } + + function buildSelectionPill({ el: targetEl, controlsLocked }) { + const tag = selectionTagLabel(targetEl); + const path = elementPath(targetEl); + const P = configureBarPalette(); + const pill = el('button', configureSelectionPillStyle({ minWidth: '32px' })); + pill.id = PREFIX + '-selection-pill'; + pill.type = 'button'; + pill.setAttribute('aria-label', 'Selected element: ' + tag); + pill.disabled = controlsLocked; + pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + pill.style.opacity = controlsLocked ? '0.58' : '1'; + pill.style.flexShrink = '0'; + + const faceStack = el('span', { + display: 'grid', placeItems: 'center', + width: '100%', minWidth: '1.25em', + lineHeight: CONFIGURE_ROW_TRACK_H, + }); + const tagFace = el('span', { + gridArea: '1 / 1', + transition: 'opacity 0.12s ease', + color: P.patina, + }); + const clearFace = el('span', { + gridArea: '1 / 1', + opacity: '0', + transition: 'opacity 0.12s ease', + color: 'oklch(58% 0.15 35)', + }); + tagFace.textContent = tag; + clearFace.textContent = '\u00D7'; + faceStack.appendChild(tagFace); + faceStack.appendChild(clearFace); + pill.appendChild(faceStack); + + const setArmed = (armed) => { + tagFace.style.opacity = armed ? '0' : '1'; + clearFace.style.opacity = armed ? '1' : '0'; + pill.style.background = armed ? P.toggleActive : 'transparent'; + pill.style.border = CONFIGURE_SELECTION_PILL_BORDER; + pill.setAttribute('aria-label', armed ? 'Clear selection' : 'Selected element: ' + tag); + }; + const arm = () => { + if (controlsLocked) { + showConfigureBarTooltip(pill, 'Apply is still running'); + return; + } + setArmed(true); + if (path) showConfigureBarTooltip(pill, path); + }; + const disarm = () => { + hideConfigureBarTooltip(); + setArmed(false); + }; + pill.addEventListener('mouseenter', arm); + pill.addEventListener('mouseleave', disarm); + pill.addEventListener('focus', arm); + pill.addEventListener('blur', disarm); + pill.addEventListener('click', (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + removeConfigureSelection(); + }); + return pill; + } + + function bindConfigureCountPillTooltip(count, controlsLocked) { + count.removeAttribute('title'); + count.addEventListener('mouseenter', () => { + if (controlsLocked) { + showConfigureBarTooltip(count, 'Apply is still running'); + return; + } + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + }); + count.addEventListener('mouseleave', hideConfigureBarTooltip); + } + + function buildConfigureActionControl({ controlsLocked, onClick }) { + const control = el('button', configureInlineControlStyle()); + const label = document.createElement('span'); + label.textContent = actionLabel(); + const caret = el('span', { + fontSize: '10px', lineHeight: '1', + marginLeft: '2px', pointerEvents: 'none', + color: 'inherit', + }); + caret.textContent = '\u25BE'; + caret.setAttribute('aria-hidden', 'true'); + control.appendChild(label); + control.appendChild(caret); + control.disabled = controlsLocked; + control.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + control.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(control, controlsLocked); + control.addEventListener('click', onClick); + return control; + } + + const VARIANT_COUNT_MIN = 1; + const VARIANT_COUNT_MAX = 4; + + function cycleSelectedCount() { + if (selectedCount >= VARIANT_COUNT_MAX) selectedCount = VARIANT_COUNT_MIN; + else selectedCount += 1; + return selectedCount; + } + + function buildConfigureCountControl({ controlsLocked, onClick }) { + const count = el('button', configureInlineControlStyle({ + fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + })); + count.textContent = '\u00D7' + selectedCount; + count.disabled = controlsLocked; + count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + count.style.opacity = controlsLocked ? '0.58' : '1'; + bindConfigureInlineControlHover(count, controlsLocked); + bindConfigureCountPillTooltip(count, controlsLocked); + count.addEventListener('click', onClick); + return count; + } + + function buildConfigureVoiceButton({ id, controlsLocked, onClick }) { + const voiceBtn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', + width: CONFIGURE_BAR_H, height: '100%', flexShrink: '0', + padding: '0', margin: '0', + border: 'none', borderRight: '1px solid ' + BP.hairline, + borderRadius: '0', background: 'transparent', + color: BP.textDim, cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + voiceBtn.id = id; + voiceBtn.type = 'button'; + voiceBtn.setAttribute('aria-label', 'Voice input'); + voiceBtn.innerHTML = ICON_PAGE_VOICE; + voiceBtn.disabled = controlsLocked; + voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; + voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + voiceBtn.addEventListener('click', onClick); + return voiceBtn; + } + + function buildConfigureTrailingCluster(controls, voiceBtn, submitBtn) { + const cluster = el('div', { + display: 'inline-flex', alignItems: 'stretch', flexShrink: '0', + height: '100%', borderLeft: '1px solid ' + BP.hairline, + }); + if (controls.length) { + const controlsWrap = el('div', { + display: 'inline-flex', alignItems: 'center', gap: '8px', + padding: '0 10px', flexShrink: '0', height: '100%', + }); + controls.forEach((control) => controlsWrap.appendChild(control)); + cluster.appendChild(controlsWrap); + } + voiceBtn.style.borderLeft = '1px solid ' + BP.hairline; + cluster.appendChild(voiceBtn); + cluster.appendChild(submitBtn); + return cluster; + } + + function buildConfigureSubmitButton({ controlsLocked, onClick, ariaLabel }) { + const btn = el('button', { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', width: CONFIGURE_BAR_H, height: CONFIGURE_BAR_H, + padding: '0', flexShrink: '0', + border: 'none', borderLeft: '1px solid ' + BP.hairline, + borderRadius: '0', + background: BP.accent, color: C.ink, + cursor: controlsLocked ? 'not-allowed' : 'pointer', + transition: 'filter 0.12s ease, transform 0.1s ease', + }); + btn.type = 'button'; + btn.setAttribute('aria-label', ariaLabel); + btn.innerHTML = ICON_CONFIGURE_SUBMIT; + btn.disabled = controlsLocked; + btn.style.opacity = controlsLocked ? '0.58' : '1'; + if (controlsLocked) btn.title = 'Apply is still running'; + btn.addEventListener('mouseenter', () => { if (!controlsLocked) btn.style.filter = 'brightness(1.1)'; }); + btn.addEventListener('mouseleave', () => btn.style.filter = 'none'); + btn.addEventListener('mousedown', () => { if (!controlsLocked) btn.style.transform = 'scale(0.97)'; }); + btn.addEventListener('mouseup', () => btn.style.transform = 'scale(1)'); + btn.addEventListener('click', onClick); + return btn; } // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs) @@ -1434,14 +1917,38 @@ let pageInteractionCursorActive = false; - /** Page-level cursor while insert mode is choosing a before/after edge. */ + function ensurePickCursorStyle() { + if (document.getElementById(PREFIX + '-pick-cursor-style')) return; + const style = document.createElement('style'); + style.id = PREFIX + '-pick-cursor-style'; + style.textContent = + 'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n' + + 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }'; + // Styles the host page, not the chrome - inside the adapter's shadow UI + // root (uiAppendStyle's target) these selectors would match nothing. + document.head.appendChild(style); + } + + /** Page-level cursor while pick or insert mode is targeting page elements. */ function syncPageInteractionCursor() { - let next = ''; + const pickCursor = state === 'PICKING' && pickActive && !insertActive; + let axisCursor = ''; if (state === 'PICKING' && insertActive) { - next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; + axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : ''; } - if (next) { - document.documentElement.style.cursor = next; + + if (pickCursor) { + ensurePickCursorStyle(); + document.documentElement.classList.add(PICK_CURSOR_CLASS); + document.documentElement.style.cursor = ''; + pageInteractionCursorActive = true; + return; + } + + document.documentElement.classList.remove(PICK_CURSOR_CLASS); + if (axisCursor) { + document.documentElement.style.cursor = axisCursor; pageInteractionCursorActive = true; } else if (pageInteractionCursorActive) { document.documentElement.style.cursor = ''; @@ -1449,6 +1956,16 @@ } } + /** + * Single entry point for interaction-state transitions. The pick-mode + * crosshair is derived from `state`, so a bare `state = ...` assignment + * leaves the page cursor out of sync with the mode it advertises. + */ + function setLiveState(next) { + state = next; + syncPageInteractionCursor(); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -1767,69 +2284,35 @@ } } + /** Stylesheet shared by the replace and insert configure rows. */ + function ensureConfigureInputStyle() { + if (uiGetById(PREFIX + '-configure-input-style')) return; + const s = document.createElement('style'); + s.id = PREFIX + '-configure-input-style'; + s.textContent = + '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + + '#' + PREFIX + '-input, #' + PREFIX + '-insert-input { box-sizing: border-box; height: ' + CONFIGURE_ROW_TRACK_H + '; line-height: ' + CONFIGURE_ROW_TRACK_H + '; padding: 0; margin: 0; caret-color: ' + CONFIGURE_PILL_TEXT + '; }' + + '#' + PREFIX + '-input::placeholder, #' + PREFIX + '-insert-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + + '#' + PREFIX + '-configure-voice[data-listening="true"] svg, #' + PREFIX + '-insert-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, #' + PREFIX + '-insert-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + + '#' + PREFIX + '-configure-voice:hover, #' + PREFIX + '-insert-voice:hover { background: oklch(27% 0 0); color: ' + BP.accent + '; }'; + uiAppendStyle(s); + } + function buildConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); - // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill) - const pill = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '4px', - padding: '5px 10px', borderRadius: '6px', - background: BP.chatSurface, color: BP.text, - fontFamily: FONT, fontSize: '12px', fontWeight: '500', - border: '1px solid ' + BP.hairline, cursor: 'pointer', - transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease', - whiteSpace: 'nowrap', flexShrink: '0', - }); - pill.textContent = actionLabel() + ' \u25BE'; - pill.disabled = controlsLocked; - pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - pill.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) pill.title = 'Apply is still running'; - pill.addEventListener('mouseenter', () => { - if (controlsLocked) return; - pill.style.background = BP.accentSoft; - pill.style.borderColor = BP.accent; - }); - pill.addEventListener('mouseleave', () => { - if (controlsLocked) return; - pill.style.background = BP.chatSurface; - pill.style.borderColor = BP.hairline; - }); - pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; }); - pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); - pill.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleActionPicker(); - }); - row.appendChild(pill); - - // Prompt field - same chat-surface chrome as the bottom Steer bar - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - transition: 'border-color 0.15s ease', - }); - inputWrap.id = PREFIX + '-configure-input-wrap'; + const inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-input'; input.type = 'text'; - input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the change'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1837,33 +2320,31 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, background 0.12s ease', + const action = buildConfigureActionControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleActionPicker(); + }, }); - voiceBtn.id = PREFIX + '-configure-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!uiGetById(PREFIX + '-configure-input-style')) { - const s = document.createElement('style'); - s.id = PREFIX + '-configure-input-style'; - s.textContent = - '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' + - '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' + - '#' + 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); }'; - uiAppendStyle(s); - } + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, + }); + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('focus', () => syncConfigureInputChrome()); input.addEventListener('blur', () => syncConfigureInputChrome()); @@ -1873,81 +2354,33 @@ e.stopPropagation(); e.preventDefault(); input.blur(); - disableInlineEdit(); - hideBar(); - renderEditBadge('hidden'); - state = 'PICKING'; - syncPageChatFocus('configure-input-escape'); + exitConfigureToPicking('configure-input-escape'); return; } - // Let arrow keys pass through to the element picker when the input is empty if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-configure-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); + const go = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Generate variants', + onClick: (e) => { e.stopPropagation(); handleGo(); }, + }); + + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([action, count], voiceBtn, go)); syncConfigureInputChrome(); - // Variant count toggle - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', - transition: 'color 0.12s ease, border-color 0.12s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.title = 'Variants: click to change'; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) count.title = 'Apply is still running'; - count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } }); - count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } }); - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - // Go button - const go = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - cursor: 'pointer', - transition: 'filter 0.12s ease, transform 0.1s ease', - flexShrink: '0', whiteSpace: 'nowrap', - }); - go.textContent = 'Go \u2192'; - go.disabled = controlsLocked; - go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - go.style.opacity = controlsLocked ? '0.58' : '1'; - if (controlsLocked) go.title = 'Apply is still running'; - go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; }); - go.addEventListener('mouseleave', () => go.style.filter = 'none'); - go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; }); - go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); - go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); - row.appendChild(go); - - // Auto-focus input after a beat if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; @@ -1956,34 +2389,20 @@ function buildInsertConfigureRow() { const controlsLocked = pendingApplyInFlight === true; const row = el('div', { - display: 'flex', alignItems: 'center', gap: '6px', + display: 'flex', alignItems: 'stretch', width: '100%', height: CONFIGURE_BAR_H, }); + row.addEventListener('pointerdown', (e) => e.stopPropagation()); + row.addEventListener('mousedown', (e) => e.stopPropagation()); + row.addEventListener('click', (e) => e.stopPropagation()); - const inputWrap = el('div', { - display: 'inline-flex', alignItems: 'center', - flex: '1', minWidth: '0', height: '28px', - borderRadius: '7px', - background: BP.chatSurface, - border: '1px solid ' + BP.hairline, - overflow: 'hidden', - 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 inputShell = el('div', configureInputShellStyle()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; input.type = 'text'; - input.placeholder = 'describe what to insert…'; + input.placeholder = ''; input.setAttribute('aria-label', 'Describe the new element'); - Object.assign(input.style, { - flex: '1', minWidth: '0', width: '100%', - padding: '0 6px', border: 'none', background: 'transparent', - fontFamily: FONT, fontSize: '11.5px', color: BP.text, - outline: 'none', - }); + Object.assign(input.style, configureInputFieldStyle()); input.disabled = controlsLocked; if (controlsLocked) { input.placeholder = 'apply is running...'; @@ -1991,20 +2410,22 @@ input.style.opacity = '0.58'; } - const voiceBtn = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - padding: '0', boxSizing: 'border-box', - width: '28px', height: '28px', flexShrink: '0', - border: 'none', background: 'transparent', - color: BP.textDim, cursor: 'pointer', + const count = buildConfigureCountControl({ + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + count.textContent = '\u00D7' + cycleSelectedCount(); + if (count.matches(':hover')) { + showConfigureBarTooltip(count, variantCountTooltipText(selectedCount)); + } + }, }); - voiceBtn.id = PREFIX + '-insert-voice'; - voiceBtn.type = 'button'; - voiceBtn.setAttribute('aria-label', 'Voice input'); - voiceBtn.innerHTML = ICON_PAGE_VOICE; - voiceBtn.disabled = controlsLocked; - voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; + + inputShell.appendChild(buildSelectionPill({ el: selectedElement, controlsLocked })); + inputShell.appendChild(input); + + ensureConfigureInputStyle(); input.addEventListener('input', () => syncInsertCreateButton()); input.addEventListener('pointerdown', (e) => e.stopPropagation()); @@ -2026,48 +2447,31 @@ } e.stopPropagation(); }); - voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); - voiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - toggleConfigureVoice(); + input.addEventListener('focus', () => syncConfigureInputChrome()); + input.addEventListener('blur', () => syncConfigureInputChrome()); + + const voiceBtn = buildConfigureVoiceButton({ + id: PREFIX + '-insert-voice', + controlsLocked, + onClick: (e) => { + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + toggleConfigureVoice(); + }, }); - inputWrap.appendChild(input); - inputWrap.appendChild(voiceBtn); - row.appendChild(inputWrap); - - const count = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 6px', - borderRadius: '5px', - border: '1px solid ' + BP.hairline, background: 'transparent', - fontFamily: MONO, fontSize: '11px', fontWeight: '600', - color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap', - }); - count.textContent = '\u00D7' + selectedCount; - count.disabled = controlsLocked; - count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; - count.style.opacity = controlsLocked ? '0.58' : '1'; - count.addEventListener('click', (e) => { - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; - count.textContent = '\u00D7' + selectedCount; - }); - row.appendChild(count); - - const create = el('button', { - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - boxSizing: 'border-box', height: '28px', padding: '0 12px', - borderRadius: '6px', - border: 'none', background: BP.accent, color: C.ink, - fontFamily: FONT, fontSize: '12px', fontWeight: '600', - flexShrink: '0', whiteSpace: 'nowrap', + const create = buildConfigureSubmitButton({ + controlsLocked, + ariaLabel: 'Create variants', + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + if (controlsLocked) { showManualApplyBusyToast(); return; } + if (!isInsertCreateEnabled(create)) return; + handleInsertCreate(); + }, }); create.id = PREFIX + '-insert-create'; - create.textContent = 'Create \u2192'; - create.disabled = controlsLocked; create.addEventListener('mouseenter', () => { if (controlsLocked) return; if (isInsertCreateEnabled(create)) { @@ -2077,15 +2481,10 @@ showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input))); }); create.addEventListener('mouseleave', hideInsertCreateTooltip); - create.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (controlsLocked) { showManualApplyBusyToast(); return; } - if (!isInsertCreateEnabled(create)) return; - handleInsertCreate(); - }); - row.appendChild(create); + row.appendChild(inputShell); + row.appendChild(buildConfigureTrailingCluster([count], voiceBtn, create)); syncInsertCreateButton(create, input); + syncConfigureInputChrome(); if (!controlsLocked) setTimeout(() => input.focus(), 60); return row; } @@ -2375,7 +2774,7 @@ position: 'fixed', zIndex: Z.picker, display: 'none', opacity: '0', transform: 'scale(0.96) translateY(4px)', - transformOrigin: 'bottom left', + transformOrigin: 'bottom right', transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, background: P.surface, border: '1px solid ' + P.border, @@ -2451,14 +2850,18 @@ chip.style.background = isActive ? P.accentSoft : 'transparent'; chip.style.color = isActive ? P.accent : P.text; }); - // Position above the bar + // Position above the bar, right-aligned to the configure bar edge. const barRect = barEl.getBoundingClientRect(); const pickerH = 170; // approximate; grows with icon + label rows let top = barRect.top - pickerH - 6; if (top < 8) top = barRect.bottom + 6; + pickerEl.style.display = 'block'; + const pickerW = pickerEl.offsetWidth; + let left = barRect.right - pickerW; + left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); Object.assign(pickerEl.style, { - top: top + 'px', left: barRect.left + 'px', - display: 'block', + top: top + 'px', + left: left + 'px', }); requestAnimationFrame(() => { pickerEl.style.opacity = '1'; @@ -2941,7 +3344,7 @@ function enterEditingMode() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - state = 'EDITING'; + setLiveState('EDITING'); hideBar(); hideAnnotOverlay(); renderEditBadge('editing'); @@ -2974,7 +3377,7 @@ function cancelEditing() { restoreInlineEditDrafts(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -2988,12 +3391,33 @@ hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); hoveredElement = null; hideHighlight(); syncPageChatFocus('editing-outside-click'); } + function teardownConfigureChrome() { + hideConfigureBarTooltip(); + // hideBar() restores unsaved EDITING drafts before it disables inline + // edit; disabling here first would wipe the draft metadata it needs. + hideBar(); + stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); + renderEditBadge('hidden'); + } + + function exitConfigureToPicking(reason, opts = {}) { + teardownConfigureChrome(); + setLiveState('PICKING'); + if (opts.clearHover) { + hoveredElement = null; + hideHighlight(); + } + syncPageChatFocus(reason); + } + // Prefer the leaf's own id/class; if it has neither (e.g. a bare ), // climb to the nearest ancestor with one. The CLI uses tag+class together, // so tag must come from the same node as the locator. @@ -3215,7 +3639,7 @@ updatePendingCounter(stashResult.pendingCount || 0); maybeShowFirstSaveToast(); disableInlineEdit(); - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showBar('configure'); showAnnotOverlay(selectedElement); renderEditBadge('idle'); @@ -3895,6 +4319,12 @@ // Edit content badge - floating button at element top-right to enter EDITING mode // + const EDIT_COPY_LABEL = 'Edit copy'; + const EDIT_COPY_ICON = + ''; + function usesShadowChromeRoot() { const root = liveUiRoot(); return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; @@ -4045,7 +4475,7 @@ editBadgeProxyRoot.appendChild(proxy); editBadgeProxyByTarget.set(target, proxy); } - proxy.title = target.title || target.textContent || 'Edit copy'; + proxy.title = target.title || target.getAttribute('aria-label') || target.textContent || EDIT_COPY_LABEL; styleEditBadgeProxy(proxy, target); } } @@ -4085,13 +4515,16 @@ } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; + // Match showHighlight's 2px outset so the badge right edge lines up with the outline. + const outlineRight = r.right + 2; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; - editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, outlineRight - bw) + 'px'; syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { + hideConfigureBarTooltip(); if (editBadgeEl) editBadgeEl.style.display = 'none'; syncEditBadgeHitProxies(); return; @@ -4129,16 +4562,36 @@ const disabled = mode === 'idle-disabled'; editBadgeEl.innerHTML = ''; const btn = document.createElement('button'); - btn.textContent = 'Edit copy'; - Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT)); + btn.type = 'button'; + btn.innerHTML = EDIT_COPY_ICON; + btn.setAttribute('aria-label', EDIT_COPY_LABEL); + Object.assign(btn.style, calloutStyle( + disabled ? MUTED : PRIMARY_TEXT, + disabled ? HAIRLINE : ACCENT, + )); + Object.assign(btn.style, { + padding: '4px', + minWidth: '22px', + width: '22px', + height: '22px', + minHeight: '22px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: '0', + letterSpacing: '0', + background: disabled ? SURFACE : ACCENT, + }); if (disabled) { btn.style.cursor = 'not-allowed'; btn.style.opacity = '0.55'; btn.disabled = true; - btn.title = 'Edit copy is disabled while the current copy edit is applying'; + const disabledTip = EDIT_COPY_LABEL + ' is disabled while the current copy edit is applying'; + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, disabledTip)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); } else { - btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; }); - btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; }); + btn.addEventListener('mouseenter', () => showConfigureBarTooltip(btn, EDIT_COPY_LABEL)); + btn.addEventListener('mouseleave', hideConfigureBarTooltip); btn.onclick = enterEditingMode; } editBadgeEl.appendChild(btn); @@ -4154,9 +4607,8 @@ cancel.onclick = cancelEditing; const save = document.createElement('button'); save.textContent = 'Save'; - Object.assign(save.style, calloutStyle(ACCENT)); - save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; }); - save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; }); + Object.assign(save.style, calloutStyle(PRIMARY_TEXT, ACCENT)); + save.style.background = ACCENT; save.onclick = applyEditing; editBadgeEl.append(cancel, save); } @@ -4392,31 +4844,142 @@ return doc.getElementById('impeccable-anchor')?.firstElementChild || null; } + function normalizeElementClassName(el) { + if (!el) return ''; + const raw = el.getAttribute?.('class'); + if (typeof raw === 'string') return raw.trim(); + if (el.className != null) { + const cls = el.className; + if (typeof cls === 'string') return cls.trim(); + if (typeof cls.baseVal === 'string') return cls.baseVal.trim(); + } + return ''; + } + + function buildPickedAnchorSnapshot(el) { + if (!el || el.nodeType !== 1) return null; + return { + tag: el.tagName, + id: el.id || '', + classes: [...el.classList], + text: (el.textContent || '').trim().slice(0, 120), + }; + } + + function isUsableInjectionAnchor(el) { + return !!el + && el.parentElement + && document.body.contains(el) + && !own(el) + && !el.closest?.('[data-impeccable-variants]'); + } + + function elementMatchesOriginalMarkup(liveEl, origContent) { + if (!isUsableInjectionAnchor(liveEl) || !origContent) return false; + // A matching id is decisive on its own: ids are unique, while the source + // tag and class names may not survive the build (component tags, hashed + // CSS-module class names). + if (origContent.id) return liveEl.id === origContent.id; + if (liveEl.tagName !== origContent.tagName) return false; + + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean) + .filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (origClasses.length > 0 && !origClasses.every((name) => liveEl.classList.contains(name))) return false; + + const origText = (origContent.textContent || '').trim(); + if (origClasses.length === 0 && origText.length >= 4) { + const liveText = (liveEl.textContent || '').trim(); + const needle = origText.slice(0, Math.min(40, origText.length)); + if (!liveText.includes(needle) && !(liveText.length >= 4 && origText.includes(liveText.slice(0, 40)))) return false; + } + return true; + } + + function findLiveElementFromAnchorSnapshot(snapshot) { + if (!snapshot) return null; + const tag = String(snapshot.tag || '').toLowerCase(); + if (!tag) return null; + if (snapshot.id) { + const byId = document.getElementById(snapshot.id); + if (isUsableInjectionAnchor(byId)) return byId; + } + const classes = (snapshot.classes || []).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + const needle = (snapshot.text || '').trim(); + const candidates = [...document.getElementsByTagName(tag)]; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (classes.length > 0 && !classes.every((name) => c.classList.contains(name))) continue; + if (!snapshot.id && classes.length === 0 && needle.length >= 4) { + const text = (c.textContent || '').trim(); + if (!text.includes(needle.slice(0, 40)) && !(text.length >= 4 && needle.includes(text.slice(0, 40)))) continue; + } + return c; + } + return null; + } + function findLiveElementForOriginalMarkup(originalMarkup) { const origContent = parseOriginalMarkupElement(originalMarkup); if (!origContent) return null; const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; + const cls = normalizeElementClassName(origContent); + const candidates = [...document.getElementsByTagName(tag)]; + if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = [...document.getElementsByTagName(tag)]; - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - if (!liveEl) { - const expectedClasses = String(cls).split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); - if (expectedClasses.length > 0) { - for (const c of candidates) { - if (own(c)) continue; - if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } - } + const byId = document.getElementById(origContent.id); + if (elementMatchesOriginalMarkup(byId, origContent)) return byId; + } + + if (cls) { + const expectedClasses = cls.split(/\s+/).filter((name) => /^[A-Za-z_-][\w-]*$/.test(name)); + if (expectedClasses.length > 0) { + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) return c; } } } - return liveEl; + + const origText = (origContent.textContent || '').trim(); + if (origText.length >= 4) { + const needle = origText.slice(0, 40); + let best = null; + let bestLen = Infinity; + for (const c of candidates) { + if (!isUsableInjectionAnchor(c)) continue; + const text = (c.textContent || '').trim(); + if (!text.includes(needle) && !(text.length >= 4 && origText.includes(text.slice(0, 40)))) continue; + if (text.length < bestLen) { best = c; bestLen = text.length; } + } + if (best) return best; + } + + return null; + } + + function resolveLiveInjectionAnchor(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const attempts = [ + selectedElement, + findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot), + findLiveElementForOriginalMarkup(originalMarkup), + ]; + for (const candidate of attempts) { + if (elementMatchesOriginalMarkup(candidate, origContent)) return candidate; + } + + if (isUsableInjectionAnchor(selectedElement) && selectedElement.tagName === origContent.tagName) { + const origClasses = normalizeElementClassName(origContent).split(/\s+/).filter(Boolean); + if (origContent.id && selectedElement.id === origContent.id) return selectedElement; + if (origClasses.length === 0) return selectedElement; + const overlap = origClasses.filter((name) => selectedElement.classList.contains(name)); + if (overlap.length >= 1) return selectedElement; + } + + return null; } function isSvelteInsertManifest(manifest) { @@ -4428,7 +4991,45 @@ const anchor = findInsertAnchorInDom(); if (anchor?.parentElement) return anchor; } - return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + return resolveLiveInjectionAnchor(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }) { + if (pendingVariantAnchorRetryObserver) pendingVariantAnchorRetryObserver.disconnect(); + const origContent = srcWrapper?.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + const originalMarkup = origContent.outerHTML; + + pendingVariantAnchorRetryObserver = new MutationObserver(() => { + // Retry once either the anchor element or the session wrapper shows up. + // A wrapper can land incomplete ("wrap HMR landed, variant insert did + // not"); injectVariantsFromSource owns both cases - it replaces an + // existing wrapper from source and clears recoveryWaitingForAnchor. + const wrapperLanded = !!document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapperLanded) { + const liveEl = resolveLiveInjectionAnchor(originalMarkup); + if (!liveEl?.parentElement) return; + } + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + injectVariantsFromSource(filePath, sessionId); + }); + pendingVariantAnchorRetryObserver.observe(document.body, { childList: true, subtree: true }); + if (checkpointReason) queueCheckpoint(checkpointReason); + } + + function enterRecoveryWaitingForAnchor({ filePath, sessionId, srcWrapper, checkpointReason, trackScroll }) { + recoveryWaitingForAnchor = true; + selectedElement = document.body; + setLiveState('GENERATING'); + showBar('generating'); + if (trackScroll !== false) startScrollTracking(); + saveSession(); + if (srcWrapper && filePath && sessionId) { + waitForVariantAnchorAndRetry({ filePath, sessionId, srcWrapper, checkpointReason }); + } else if (checkpointReason) { + queueCheckpoint(checkpointReason); + } } function loadSvelteRuntime(runtimeModule) { @@ -4595,7 +5196,7 @@ previewFile: manifestPath, previewMode: 'svelte-component', }); - if (state !== 'CYCLING') state = 'GENERATING'; + if (state !== 'CYCLING') setLiveState('GENERATING'); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { @@ -4605,7 +5206,7 @@ expectedVariants = arrivedVariants; visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; await mountSvelteComponentVariant(visibleVariant || 1); - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); return; @@ -4621,15 +5222,8 @@ 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'); + enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true }); waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); - showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } @@ -4692,7 +5286,7 @@ } selectedElement = mountTarget.firstElementChild || mountTarget; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -4741,6 +5335,7 @@ hideShaderOverlay(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearSession(); clearHandled(); @@ -4750,7 +5345,7 @@ arrivedVariants = 0; visibleVariant = 0; selectedElement = null; - state = 'PICKING'; + setLiveState('PICKING'); hideBar(); if (message) showToast(message, 5000); } @@ -4799,21 +5394,26 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); if (!liveEl) { 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); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. @@ -4834,7 +5434,7 @@ // Update selectedElement to the visible variant's content selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); showOrUpdateCyclingBar(); @@ -5240,7 +5840,7 @@ if (expected > 0) expectedVariants = expected; if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - state = 'CYCLING'; + setLiveState('CYCLING'); recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); @@ -5326,7 +5926,8 @@ syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); - if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('sse-connected'); break; case 'agent_polling': @@ -5352,7 +5953,7 @@ // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -5402,7 +6003,7 @@ case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -5412,7 +6013,7 @@ showToast('Error: ' + msg.message, 5000); hideBar(); renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); break; } }; @@ -5450,7 +6051,7 @@ // transient disconnect as an explicit discard. selectedElement = null; selectedAction = 'impeccable'; - state = recoveryState; + setLiveState(recoveryState); if (currentSessionId) saveSession(); } @@ -5592,15 +6193,7 @@ && !selectedElement.contains(e.target) ) { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - hideBar(); - stopScrollTracking(); - hideAnnotOverlay(); - clearAnnotations(); - renderEditBadge('hidden'); - state = 'PICKING'; - hoveredElement = null; - hideHighlight(); - syncPageChatFocus('configure-outside-click'); + exitConfigureToPicking('configure-outside-click', { clearHover: true }); return; } if (state === 'PICKING' && insertActive) { @@ -5617,13 +6210,12 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); showBar('configure'); startScrollTracking(); - syncPageInteractionCursor(); return; } if (state !== 'PICKING' || !pickActive) return; @@ -5636,7 +6228,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5721,6 +6313,16 @@ sendEvent({ type: 'prefetch', pageUrl: path }); } + function shouldPassthroughElementNav(deepActive, e) { + if (!deepActive || !own(deepActive)) return false; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false; + if (!/^(INPUT|TEXTAREA)$/.test(deepActive.tagName || '')) return false; + if (deepActive.value) return false; + if (deepActive.id === PREFIX + '-input' && state === 'CONFIGURING') return true; + if (deepActive.id === PREFIX + '-page-chat-input' && state === 'PICKING') return true; + return false; + } + function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; @@ -5729,6 +6331,7 @@ deepActive && own(deepActive) && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + && !shouldPassthroughElementNav(deepActive, e) ) { return; } @@ -5767,14 +6370,15 @@ if (state === 'EDITING') { cancelEditing(); return; } if (state === 'CONFIGURING') { if (configureKind === 'insert') { cancelInsertConfigure(); return; } - disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return; + exitConfigureToPicking('escape-from-configure'); + return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { if (insertActive) toggleInsert(); else if (pickActive) togglePick(); - else { hideHighlight(); state = 'IDLE'; } + else { hideHighlight(); setLiveState('IDLE'); } return; } } @@ -5798,7 +6402,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - state = 'CONFIGURING'; + setLiveState('CONFIGURING'); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -5859,6 +6463,7 @@ // screenshot is uploaded (or capture fails - we still emit, just without // screenshotPath). const elForCapture = selectedElement; + pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture); const captureRect = elForCapture.getBoundingClientRect(); const snapshot = { comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), @@ -5879,7 +6484,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); // Disable the Edit badge: starting a manual text edit mid-generation would // conflict with the variant wrap that's about to land in the same DOM // region. Only swap if the badge was visible - picked elements with no @@ -5904,7 +6509,7 @@ clearInsertPicking(); configureKind = 'replace'; selectedElement = null; - state = insertActive ? 'PICKING' : 'IDLE'; + setLiveState(insertActive ? 'PICKING' : 'IDLE'); hideHighlight(); syncPageChatFocus('insert-configure-cancel'); } @@ -5954,7 +6559,7 @@ hideAnnotOverlay(); clearAnnotations(); - state = 'GENERATING'; + setLiveState('GENERATING'); showBar('generating'); startScrollTracking(); saveSession(); @@ -6712,7 +7317,7 @@ void main() { || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - state = 'SAVING'; + setLiveState('SAVING'); updateBarContent('saving'); pendingAcceptedSession = { id: acceptedSessionId, @@ -6727,7 +7332,7 @@ void main() { .then(() => {}) .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; - state = 'CYCLING'; + setLiveState('CYCLING'); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -6746,7 +7351,7 @@ void main() { if (pending.isSvelteComponent) { commitAcceptedSvelteComponentToDom(pending.id); } - state = 'CONFIRMED'; + setLiveState('CONFIRMED'); updateBarContent('confirmed'); scheduleAcceptCleanup(pending); return true; @@ -6870,7 +7475,7 @@ void main() { selectedAction = 'impeccable'; pendingAcceptedSession = null; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } function commitAcceptedVariantToDom(sessionId, variantId) { @@ -6922,6 +7527,7 @@ void main() { currentPreviewFile = null; currentPreviewMode = null; recoveryWaitingForAnchor = false; + pickedAnchorSnapshot = null; } function rememberSessionFileMeta(meta = {}) { @@ -6946,6 +7552,7 @@ void main() { if (!saved) return; rememberSessionFileMeta(saved); if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor; if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; if (saved.previewMode) currentPreviewMode = saved.previewMode; @@ -7012,9 +7619,10 @@ void main() { || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) || (arrivedVariants > 0 ? 1 : 0); - selectedElement = document.body; - state = 'GENERATING'; - recoveryWaitingForAnchor = true; + const restoredAnchor = findLiveElementFromAnchorSnapshot(pickedAnchorSnapshot); + selectedElement = restoredAnchor || document.body; + setLiveState('GENERATING'); + recoveryWaitingForAnchor = !restoredAnchor; showBar('generating'); startScrollTracking(); if (variantObserver) variantObserver.disconnect(); @@ -7030,7 +7638,6 @@ void main() { return true; } - showToast('Variants ready. Reveal the selected element to resume.', 15000); return true; } @@ -7059,6 +7666,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, + pickedAnchor: pickedAnchorSnapshot || undefined, }); } @@ -7118,6 +7726,7 @@ void main() { hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; } stopScrollLock(); clearScrollY(); finalizeInsertSession(); @@ -7127,15 +7736,21 @@ void main() { currentSessionId = null; selectedAction = 'impeccable'; renderEditBadge('hidden'); - state = 'PICKING'; + setLiveState('PICKING'); } // // Toast // + function dismissToast() { + if (!toastEl) return; + toastEl.remove(); + toastEl = null; + } + function showToast(message, duration) { - if (toastEl) toastEl.remove(); + dismissToast(); // Stack the toast above the global bar (which sits at bottom:14px) so // the two never overlap. Read the bar's actual rect - its height varies // with hover-expanded labels - and fall back to a sensible default @@ -7224,7 +7839,7 @@ void main() { : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); selectedElement = resolveSvelteComponentAnchor() || wrapper.parentElement; - state = 'CYCLING'; + setLiveState('CYCLING'); hideShaderOverlay(); showBar('cycling'); startScrollTracking(); @@ -7260,7 +7875,7 @@ void main() { const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; const origEl = pickVariantContent(wrapper, 'original'); - state = resumedState; + setLiveState(resumedState); if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) { selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper; } else { @@ -7419,6 +8034,8 @@ void main() { let voiceCtx = null; const PAGE_CHAT_COLLAPSED_W = '88px'; const PAGE_CHAT_PROCESSING_W = '76px'; + const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…'; + const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…'; const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; @@ -7509,13 +8126,16 @@ void main() { function syncPageChatChrome() { if (!pageChatEl) return; const P = pageChatPalette(); + const inputFocused = pageChatInput && activeElementDeep() === pageChatInput; pageChatEl.style.background = P.chatSurface; - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); + pageChatEl.style.borderColor = 'transparent'; if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim; const chatIcon = pageChatEl?.firstElementChild; - if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim; + if (chatIcon) { + chatIcon.style.color = steerLocked + ? P.patinaPale + : (inputFocused || pageChatExpanded ? P.text : P.textDim); + } if (pageChatInput) pageChatInput.style.color = P.text; if (pageChatVoiceBtn) { const listening = pageChatVoiceBtn.dataset.listening === 'true'; @@ -7686,24 +8306,44 @@ void main() { function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; const focused = activeElementDeep() === pageChatInput; + const typingReady = focused && !steerLocked; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; - const P = pageChatPalette(); - pageChatEl.style.borderColor = steerLocked - ? P.patinaSoft - : (pageChatExpanded ? P.accentSoft : P.hairline); pageChatEl.style.boxShadow = 'none'; + + if (pageChatExpanded) { + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return; + } + + if (typingReady) { + // Collapsed type-to-steer: show the real input + caret instead of a + // truncated patina "Steer" label with an invisible focused field. + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 4px'; + pageChatInput.style.opacity = '1'; + pageChatInput.style.pointerEvents = 'auto'; + return; + } + + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; if (pageChatHint) { - pageChatHint.style.color = steerLocked - ? P.patinaPale - : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim); - } - if (!pageChatExpanded) { - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; - pageChatInput.style.pointerEvents = focused ? 'auto' : 'none'; - if (pageChatHint) pageChatHint.style.visibility = ''; + pageChatHint.style.display = ''; + pageChatHint.style.opacity = '1'; + pageChatHint.style.visibility = ''; } + pageChatInput.style.width = '0'; + pageChatInput.style.padding = '0'; + pageChatInput.style.opacity = '0'; + pageChatInput.style.pointerEvents = 'none'; } function focusSteerChat(reason) { @@ -7723,6 +8363,7 @@ void main() { try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); + syncPageChatChrome(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), @@ -7769,6 +8410,7 @@ void main() { pageChatEl.dataset.expanded = 'true'; pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED; if (pageChatHint) { pageChatHint.style.display = 'none'; pageChatHint.style.opacity = '0'; @@ -7780,6 +8422,20 @@ void main() { return true; } + function armPageChatForTyping(opts = {}) { + if (!pageChatEl || !pageChatInput || steerLocked) return false; + const expand = opts.expand !== false; + const focus = opts.focus !== false; + if (expand && !pageChatExpanded) { + preparePageChatInputForTyping(); + syncPageChatChrome(); + } + if (focus) return focusPageChatInput('arm-page-chat'); + syncPageChatFocusRing(); + syncPageChatChrome(); + return true; + } + function focusPageChatInput(reason) { if (!preparePageChatInputForTyping() || steerLocked) return false; try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } @@ -7938,7 +8594,9 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = uiGetById(PREFIX + '-configure-voice'); + // The bar shows either the replace row's voice button or the insert + // row's - both run voice through the 'configure' mode. + const voiceBtn = uiGetById(PREFIX + '-configure-voice') || uiGetById(PREFIX + '-insert-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -8180,12 +8838,12 @@ void main() { height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px', borderRadius: '7px', background: P.chatSurface, - border: '1px solid ' + P.hairline, + border: '1px solid transparent', overflow: 'hidden', cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'border-color 0.15s ease', + transition: 'width 0.18s ease, border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; @@ -8211,13 +8869,14 @@ void main() { pageChatInput = document.createElement('input'); pageChatInput.id = PREFIX + '-page-chat-input'; pageChatInput.type = 'text'; - pageChatInput.placeholder = 'Steer the page…'; + pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_COLLAPSED; pageChatInput.setAttribute('aria-label', 'Steer the page'); Object.assign(pageChatInput.style, { flex: '1', minWidth: '0', width: '0', padding: '0', border: 'none', background: 'transparent', fontFamily: FONT, fontSize: '11.5px', color: P.text, outline: 'none', opacity: '0', pointerEvents: 'none', + caretColor: P.accent, transition: 'opacity 0.15s ease', }); @@ -8252,18 +8911,23 @@ void main() { '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' + '@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-input { caret-color: oklch(84% 0.19 80.46); }' + + '#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; uiAppendStyle(s); } - pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('pointerdown', (e) => { + keepSteerPointerInside(e); + if (steerLocked || pageChatVoiceBtn.contains(e.target)) return; + armPageChatForTyping({ expand: true, focus: false }); + }); pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat({ focus: false }); - focusPageChatInput('page-chat-click'); + armPageChatForTyping({ expand: true, focus: true }); }); pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); @@ -8286,7 +8950,9 @@ void main() { }); pageChatInput.addEventListener('focus', () => { + steerInputWasFocused = true; syncPageChatFocusRing(); + syncPageChatChrome(); }); pageChatInput.addEventListener('blur', () => { @@ -8953,14 +9619,15 @@ void main() { cancelInsertConfigure(); return; } + teardownConfigureChrome(); hideHighlight(); - hideBar(); hideActionPicker(); selectedElement = null; + hoveredElement = null; configureKind = 'replace'; - if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; + if (state === 'PICKING' || state === 'CONFIGURING') setLiveState('IDLE'); } else { - if (state === 'IDLE') state = 'PICKING'; + if (state === 'IDLE') setLiveState('PICKING'); } syncPageChatFocus('toggle-pick'); } @@ -8976,10 +9643,10 @@ void main() { selectedElement = null; configureKind = 'replace'; if (state === 'CONFIGURING') cancelInsertConfigure(); - else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING'; + else if (state === 'IDLE' || state === 'PICKING') setLiveState('PICKING'); } else { clearInsertPicking(); - if (state === 'PICKING' && !pickActive) state = 'IDLE'; + if (state === 'PICKING' && !pickActive) setLiveState('IDLE'); } saveInteractionPrefs(); updateGlobalBarState(); @@ -9060,6 +9727,7 @@ void main() { pageChatVoiceBtn = null; pageChatExpanded = false; if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; } + if (configureBarTooltipEl) { configureBarTooltipEl.remove(); configureBarTooltipEl = null; } if (highlightEl) { highlightEl.remove(); highlightEl = null; } if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } if (barEl) { barEl.remove(); barEl = null; } @@ -9073,7 +9741,8 @@ void main() { window.removeEventListener('message', onDetectMessage); // Remove detection overlays window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - state = 'IDLE'; + setLiveState('IDLE'); + document.getElementById(PREFIX + '-pick-cursor-style')?.remove(); window.__IMPECCABLE_LIVE_INIT__ = false; console.log('[impeccable] Live mode exited.'); } @@ -10250,6 +10919,8 @@ void main() { console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } + if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); + syncPageInteractionCursor(); syncPageChatFocus('init-complete'); } diff --git a/plugin/skills/impeccable/scripts/live/ui-core.mjs b/plugin/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/plugin/skills/impeccable/scripts/live/ui-core.mjs +++ b/plugin/skills/impeccable/scripts/live/ui-core.mjs @@ -42,9 +42,10 @@ export const LIVE_UI_SURFACES = Object.freeze([ 'impeccable-live-highlight', 'impeccable-live-tooltip', 'impeccable-live-bar', - 'impeccable-live-configure-input-wrap', + 'impeccable-live-selection-pill', 'impeccable-live-input', 'impeccable-live-configure-voice', + 'impeccable-live-configure-bar-tooltip', ], states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'], },