diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/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/.agents/skills/impeccable/scripts/live/ui-core.mjs b/.agents/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.agents/skills/impeccable/scripts/live/ui-core.mjs +++ b/.agents/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/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/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/.claude/skills/impeccable/scripts/live/ui-core.mjs b/.claude/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.claude/skills/impeccable/scripts/live/ui-core.mjs +++ b/.claude/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/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/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/.cursor/skills/impeccable/scripts/live/ui-core.mjs b/.cursor/skills/impeccable/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/.cursor/skills/impeccable/scripts/live/ui-core.mjs +++ b/.cursor/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/site/pages/index.astro b/site/pages/index.astro index a94bf251c..37258ee63 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -197,15 +197,29 @@ import '../styles/testimonials.css';
- - - - - - +
+ + + + +
+
+
+ + +
+
+ + +
+
@@ -545,10 +559,23 @@ import '../styles/testimonials.css';
- bolder - - ×3 - Go +
+ h1 +
+
+
+ bolder + ×3 +
+
+ + +
+
diff --git a/site/pages/live-mode/index.astro b/site/pages/live-mode/index.astro index a6f06d9f6..48cdcd108 100644 --- a/site/pages/live-mode/index.astro +++ b/site/pages/live-mode/index.astro @@ -106,15 +106,29 @@ import '../../styles/live-mode-kinpaku.css';
- - - - - - +
+ + + + +
+
+
+ + +
+
+ + +
+
diff --git a/site/styles/kinpaku-kit.css b/site/styles/kinpaku-kit.css index e3558a221..acc01f55b 100644 --- a/site/styles/kinpaku-kit.css +++ b/site/styles/kinpaku-kit.css @@ -1198,18 +1198,17 @@ margin: 0 4px 0 6px; border-radius: 5px; background: var(--ks-graphite); - border: 1px solid oklch(92% 0 0 / 0.1); + border: 1px solid transparent; overflow: hidden; cursor: pointer; flex-shrink: 0; - transition: border-color 0.15s ease; + transition: width 0.18s ease, border-color 0.15s ease; } .home-kinpaku .live-demo-gbar-chat.is-expanded, .live-mode-kinpaku .live-demo-gbar-chat.is-expanded { width: min(220px, 42vw); cursor: text; - border-color: oklch(78% 0.12 82 / 0.55); } .home-kinpaku .live-demo-gbar-chat-icon, @@ -1251,9 +1250,23 @@ outline: none; opacity: 0; pointer-events: none; + caret-color: var(--ks-kinpaku); transition: opacity 0.15s ease; } +.home-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-hint, +.live-mode-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-hint { + display: none; +} + +.home-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-input, +.live-mode-kinpaku .live-demo-gbar-chat[data-input-focused="true"]:not([data-expanded="true"]) .live-demo-gbar-chat-input { + width: auto; + padding: 0 4px; + opacity: 1; + pointer-events: auto; +} + .home-kinpaku .live-demo-gbar-chat.is-expanded .live-demo-gbar-chat-hint, .live-mode-kinpaku .live-demo-gbar-chat.is-expanded .live-demo-gbar-chat-hint { display: none; @@ -1316,47 +1329,159 @@ /* Context bar sub-elements (the inline action panel that appears next to a picked element during "configure" / "cycling" / "accepted" states). */ -.home-kinpaku .live-demo-ctx-pill, -.live-mode-kinpaku .live-demo-ctx-pill { +.home-kinpaku .live-demo-ctx[data-phase="configuring"], +.live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] { + padding: 0; background: var(--ks-graphite-2); - border: 1px solid oklch(92% 0 0 / 0.12); - color: var(--ks-champagne); + overflow: hidden; } -/* Even heights across the configure row: the pill, input, count, and Go button - share one 30px baseline instead of stepping up and down. */ -.home-kinpaku .live-demo-ctx-row--configure > *, -.live-mode-kinpaku .live-demo-ctx-row--configure > * { - height: 30px; - box-sizing: border-box; +.home-kinpaku .live-demo-ctx-command, +.live-mode-kinpaku .live-demo-ctx-command { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + height: 36px; + padding: 0 6px 0 7px; + background: transparent; + border: 0; + overflow: hidden; +} + +.home-kinpaku .live-demo-ctx-trailing, +.live-mode-kinpaku .live-demo-ctx-trailing { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + border-left: 1px solid oklch(92% 0 0 / 0.12); +} + +.home-kinpaku .live-demo-ctx-modifiers, +.live-mode-kinpaku .live-demo-ctx-modifiers { display: inline-flex; align-items: center; + gap: 8px; + padding: 0 10px; + flex-shrink: 0; } -.home-kinpaku .live-demo-ctx-pill-caret, -.live-mode-kinpaku .live-demo-ctx-pill-caret { +.home-kinpaku .live-demo-ctx-selection, +.live-mode-kinpaku .live-demo-ctx-selection { + box-sizing: border-box; + min-width: 28px; + height: auto; + padding: 1px 4px; + border: 1px solid oklch(70% 0.12 188); + border-radius: 7px; + flex-shrink: 0; + background: transparent; + color: oklch(70% 0.12 188); + font-family: var(--ks-mono); + font-size: 12px; + font-weight: 600; + line-height: 18px; + transition: background 0.15s ease, color 0.15s ease; +} + +.home-kinpaku .live-demo-ctx-action, +.home-kinpaku .live-demo-ctx-count, +.live-mode-kinpaku .live-demo-ctx-action, +.live-mode-kinpaku .live-demo-ctx-count { + box-sizing: border-box; + height: 18px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; color: var(--ks-text-muted); -} - -.home-kinpaku .live-demo-ctx-input, -.live-mode-kinpaku .live-demo-ctx-input { - background: var(--ks-graphite); - border: 1px solid oklch(92% 0 0 / 0.12); - color: var(--ks-champagne); -} - -.home-kinpaku .live-demo-ctx-caret, -.live-mode-kinpaku .live-demo-ctx-caret { - background: var(--ks-kinpaku); + font-size: 12px; + font-weight: 500; + line-height: 18px; + transition: color 0.12s ease; } .home-kinpaku .live-demo-ctx-count, .live-mode-kinpaku .live-demo-ctx-count { - background: var(--ks-graphite-2); - border: 1px solid oklch(92% 0 0 / 0.12); + font-family: var(--ks-mono); + font-weight: 600; +} + +.home-kinpaku .live-demo-ctx-row--configure, +.live-mode-kinpaku .live-demo-ctx-row--configure { + width: 100%; + min-height: 36px; +} + +.home-kinpaku .live-demo-ctx-actions, +.live-mode-kinpaku .live-demo-ctx-actions { + display: inline-flex; + align-items: stretch; + border-left: 1px solid oklch(92% 0 0 / 0.12); +} + +.home-kinpaku .live-demo-ctx-voice, +.live-mode-kinpaku .live-demo-ctx-voice { + width: 36px; + height: 36px; + padding: 0; + border: 0; + border-right: 1px solid oklch(92% 0 0 / 0.12); + border-radius: 0; + background: transparent; color: var(--ks-text-muted); } +.home-kinpaku .live-demo-ctx-actions .live-demo-ctx-go, +.live-mode-kinpaku .live-demo-ctx-actions .live-demo-ctx-go { + width: 36px; + height: 36px; + padding: 0; + border-radius: 0; + border-left: 0; +} + +.home-kinpaku .live-demo-ctx-action-caret, +.live-mode-kinpaku .live-demo-ctx-action-caret { + font-size: 10px; + color: inherit; +} + +.home-kinpaku .live-demo-ctx-selection:hover, +.live-mode-kinpaku .live-demo-ctx-selection:hover { + background: oklch(27% 0 0); +} + +.home-kinpaku .live-demo-ctx-action:hover, +.home-kinpaku .live-demo-ctx-count:hover, +.live-mode-kinpaku .live-demo-ctx-action:hover, +.live-mode-kinpaku .live-demo-ctx-count:hover { + color: var(--ks-text); +} + +.home-kinpaku .live-demo-ctx-input, +.live-mode-kinpaku .live-demo-ctx-input { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + height: 18px; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-size: 12px; + font-weight: 500; + line-height: 18px; + color: oklch(94% 0.02 82); +} + +.home-kinpaku .live-demo-ctx-caret, +.live-mode-kinpaku .live-demo-ctx-caret { + background: oklch(94% 0.02 82); +} + .home-kinpaku .live-demo-ctx-go, .live-mode-kinpaku .live-demo-ctx-go, .home-kinpaku .live-demo-ctx-accept, diff --git a/site/styles/light-mode.css b/site/styles/light-mode.css index 08a81538a..bdc81401f 100644 --- a/site/styles/light-mode.css +++ b/site/styles/light-mode.css @@ -1364,15 +1364,54 @@ html.light .live-mode-kinpaku .live-demo-gbar-chat-input { was an 11%-darker olive-cast block that read as heavy and unrefined on the white bar. A barely-there warm off-white recess with a crisp hairline reads as polished product chrome — the border does the separating, not a gray slab. */ -html.light .home-kinpaku .live-demo-ctx-pill, -html.light .home-kinpaku .live-demo-ctx-input, -html.light .home-kinpaku .live-demo-ctx-count, -html.light .live-mode-kinpaku .live-demo-ctx-pill, -html.light .live-mode-kinpaku .live-demo-ctx-input, -html.light .live-mode-kinpaku .live-demo-ctx-count { - background: oklch(97.5% 0.004 95); - border-color: oklch(25% 0.02 95 / 0.14); - color: var(--ks-text); +/* The configure-phase bar mirrors the real overlay chrome, which keeps a dark + surface in both themes. The pale light-mode bar from the rule above would + swallow the near-white prompt text below, so the configuring phase opts back + into the overlay's dark surface. */ +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"], +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] { + background: oklch(15% 0.008 95); + border-color: oklch(92% 0 0 / 0.14); +} + +html.light .home-kinpaku .why-live-frame > .why-live-ctx.live-demo-ctx[data-phase="configuring"] { + background: oklch(15% 0.008 95); + border-color: oklch(92% 0 0 / 0.14); +} + +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-input, +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-input { + background: transparent; + border-color: transparent; + color: oklch(94% 0.02 82); +} + +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-caret, +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-caret { + background: oklch(94% 0.02 82); +} + +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-selection, +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-selection { + background: transparent; + border: 1px solid oklch(70% 0.12 188); + color: oklch(70% 0.12 188); +} + +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-action, +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-count, +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-action, +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-count { + background: transparent; + border: 0; + /* Light muted tone from the base sheet: these sit on the dark configure + surface, where the light-theme text-muted token would vanish. */ + color: oklch(63% 0.024 82); +} + +html.light .home-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-voice, +html.light .live-mode-kinpaku .live-demo-ctx[data-phase="configuring"] .live-demo-ctx-voice { + color: oklch(63% 0.024 82); } html.light .home-kinpaku .live-demo-ctx-go, diff --git a/site/styles/live-mode.css b/site/styles/live-mode.css index 0c5308da4..9734f7b10 100644 --- a/site/styles/live-mode.css +++ b/site/styles/live-mode.css @@ -417,64 +417,163 @@ display: flex; } -/* Configure row: [delight ▾] [input] [×3] [Go →] */ -.live-demo-ctx-pill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 5px 10px; - background: var(--color-ink); - color: var(--color-paper); - border: 0; - border-radius: 6px; - font-family: var(--font-body); - font-size: 12px; - font-weight: 500; - cursor: pointer; - white-space: nowrap; +.live-demo-ctx[data-phase="configuring"] { + padding: 0; + background: oklch(15% 0.008 95); + overflow: hidden; } -.live-demo-ctx-pill-caret { font-size: 9px; opacity: 0.7; margin-left: 2px; } -.live-demo-ctx-input { - display: inline-flex; + +/* Configure row: [h1 pill | input …] | [adapt ▾ ×3 | voice →] */ +.live-demo-ctx-row--configure { + width: 100%; + min-height: 36px; +} +.live-demo-ctx-command { + display: flex; align-items: center; flex: 1; min-width: 180px; - padding: 5px 8px; + height: 36px; + padding: 0 6px 0 7px; + background: transparent; + border: 0; + overflow: hidden; +} +.live-demo-ctx-trailing { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + border-left: 1px solid oklch(92% 0 0 / 0.12); +} +.live-demo-ctx-modifiers { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 10px; + flex-shrink: 0; +} +.live-demo-ctx-selection { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + box-sizing: border-box; + min-width: 28px; + height: auto; + padding: 1px 4px; + background: transparent; + color: oklch(70% 0.12 188); + border: 1px solid oklch(70% 0.12 188); + border-radius: 7px; + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + line-height: 18px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease; +} +.live-demo-ctx-selection:hover { + background: oklch(27% 0 0); +} +.live-demo-ctx-action, +.live-demo-ctx-count { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + box-sizing: border-box; + height: 18px; + padding: 0; + background: transparent; + color: oklch(63% 0.024 82); + border: 0; + border-radius: 0; font-family: var(--font-body); font-size: 12px; - color: var(--color-ink); + font-weight: 500; + line-height: 18px; + cursor: pointer; + white-space: nowrap; + transition: color 0.12s ease; +} +.live-demo-ctx-count { + font-family: var(--font-mono); + font-weight: 600; +} +.live-demo-ctx-action:hover, +.live-demo-ctx-count:hover { + color: oklch(84% 0.035 82); +} +.live-demo-ctx-action-caret { + font-size: 10px; + margin-left: 2px; + color: inherit; +} +.live-demo-ctx-input { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + height: 18px; + padding: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 500; + line-height: 18px; + color: oklch(94% 0.02 82); + background: transparent; + border: 0; +} +.live-demo-ctx-actions { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + border-left: 1px solid oklch(92% 0 0 / 0.12); +} +.live-demo-ctx-voice { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: transparent; + color: oklch(63% 0.024 82); + border: 0; + border-right: 1px solid oklch(22% 0 0); + border-radius: 0; + cursor: pointer; } .live-demo-ctx-caret { display: inline-block; width: 1px; height: 13px; - background: var(--color-ink); + background: oklch(94% 0.02 82); margin-left: 2px; animation: liveDemoCaret 1s steps(1) infinite; } @keyframes liveDemoCaret { 50% { opacity: 0; } } -.live-demo-ctx-count { - padding: 4px 6px; - background: transparent; - border: 1px solid var(--color-mist); - border-radius: 5px; - font-family: var(--font-mono); - font-size: 11px; - font-weight: 600; - color: var(--color-ash); - cursor: pointer; -} .live-demo-ctx-go { - padding: 5px 12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + box-sizing: border-box; + padding: 0; background: var(--color-accent); color: var(--color-paper); border: 0; - border-radius: 6px; + border-left: 1px solid oklch(22% 0 0); + border-radius: 0; font-family: var(--font-body); - font-size: 12px; - font-weight: 600; + font-size: 0; + line-height: 0; cursor: pointer; white-space: nowrap; } diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 9092338eb..539cd07d9 100644 --- a/skill/scripts/live-browser.js +++ b/skill/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/skill/scripts/live/ui-core.mjs b/skill/scripts/live/ui-core.mjs index 4b7adde8b..bbb2c5c35 100644 --- a/skill/scripts/live/ui-core.mjs +++ b/skill/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/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 3858d266f..27e814c06 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -128,7 +128,7 @@ describe('live-browser.js regression guards', () => { it('exits inline editing directly on outside click', () => { assert.match( SOURCE, - /function cancelEditingToPicking\(\) \{[\s\S]{0,600}?state = 'PICKING';/, + /function cancelEditingToPicking\(\) \{[\s\S]{0,600}?setLiveState\('PICKING'\);/, 'outside-click editing cancel should avoid rebuilding configure UI before hiding it', ); assert.match( @@ -201,7 +201,7 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /function handleServerLost\(\)[\s\S]{0,300}?const recoveryState = currentSessionId \? state : 'IDLE';[\s\S]{0,1200}?state = recoveryState;[\s\S]{0,120}?if \(currentSessionId\) saveSession\(\);/, + /function handleServerLost\(\)[\s\S]{0,300}?const recoveryState = currentSessionId \? state : 'IDLE';[\s\S]{0,1200}?setLiveState\(recoveryState\);[\s\S]{0,120}?if \(currentSessionId\) saveSession\(\);/, 'server-lost cleanup should keep the current session phase in local recovery state instead of rewriting it to GENERATING', ); }); @@ -305,6 +305,61 @@ describe('live-browser.js regression guards', () => { /steer-blur-recover/, 'steer blur should recover focus for type-to-steer when not selecting page text', ); + assert.match( + SOURCE, + /function syncPageChatFocusRing\(\)[\s\S]{0,900}?typingReady[\s\S]{0,500}?pageChatInput\.style\.opacity = '1'/, + 'collapsed steer focus must reveal the real input and caret instead of a patina truncated label', + ); + assert.match( + SOURCE, + /armPageChatForTyping\([\s\S]{0,120}?expand: true/, + 'steer pill pointerdown must expand before click so focus never flashes a dead collapsed state', + ); + assert.match( + SOURCE, + /function syncPageChatChrome\(\)[\s\S]{0,400}?pageChatEl\.style\.borderColor = 'transparent'/, + 'steer pill must stay borderless; surface contrast carries the affordance', + ); + assert.match( + SOURCE, + /floating bar surface IS the input; modifier pills sit left of the field/, + 'element configure bar must use the bar surface as the input, not a nested field', + ); + }); + + it('empty configure and steer inputs passthrough arrow nav to handleKeyDown', () => { + assert.match( + SOURCE, + /function shouldPassthroughElementNav\(deepActive, e\)[\s\S]{0,500}?PREFIX \+ '-input' && state === 'CONFIGURING'/, + 'configure prompt must passthrough empty arrow keys for sibling navigation', + ); + assert.match( + SOURCE, + /function shouldPassthroughElementNav\(deepActive, e\)[\s\S]{0,700}?PREFIX \+ '-page-chat-input' && state === 'PICKING'/, + 'steer prompt must passthrough empty arrow keys while picking', + ); + assert.match( + SOURCE, + /&& !shouldPassthroughElementNav\(deepActive, e\)/, + 'global input guard must honor empty-input arrow passthrough', + ); + }); + + it('configure input Escape tears down annotation overlay before returning to picking', () => { + // The configure prompt auto-focuses. While focused, the global keydown + // handler bails on own() inputs, so this local Escape path must hide the + // annotation overlay. Leaving it active strands a crosshair layer that + // flashes comment pins on every click after pick mode is turned off. + assert.match( + SOURCE, + /input\.addEventListener\('keydown', \(e\) => \{[\s\S]{0,400}?if \(e\.key === 'Escape'\) \{[\s\S]{0,200}?exitConfigureToPicking\('configure-input-escape'\)/, + 'configure input Escape must call exitConfigureToPicking so annot overlay is removed', + ); + assert.match( + SOURCE, + /function togglePick\(\)[\s\S]{0,500}?teardownConfigureChrome\(\);/, + 'togglePick off must tear down configure chrome so a stuck annot overlay cannot survive IDLE', + ); }); it('pick mode preference persists in localStorage', () => { @@ -325,7 +380,7 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /if \(state === 'IDLE' && \(pickActive \|\| insertActive\)\) state = 'PICKING';/, + /if \(state === 'IDLE' && \(pickActive \|\| insertActive\)\) setLiveState\('PICKING'\);/, 'SSE connected must arm insert mode when saved preference has insert on', ); }); @@ -386,9 +441,19 @@ describe('live-browser.js regression guards', () => { assert.match(SOURCE, /border: '2px dotted ' \+ BP\.accent/, 'placeholder border matches insert line (dotted)'); assert.match( SOURCE, - /function syncPageInteractionCursor\(\)[\s\S]{0,280}?cursorForInsertAxis/, + /function syncPageInteractionCursor\(\)[\s\S]{0,420}?cursorForInsertAxis/, 'insert picking cursor follows row/column axis', ); + assert.match( + SOURCE, + /function ensurePickCursorStyle\(\)[\s\S]{0,420}?cursor: crosshair !important/, + 'pick mode injects a crosshair cursor that wins over page pointer styles', + ); + assert.match( + SOURCE, + /document\.documentElement\.classList\.add\(PICK_CURSOR_CLASS\)/, + 'pick mode toggles a document-level class for the crosshair cursor', + ); assert.match(SOURCE, /function hitSiblingInsertGap\(/, 'insert mode detects gaps between siblings'); assert.match(SOURCE, /function resolveInsertHover\(/, 'insert hover resolves axis-aware boundaries'); assert.match(SOURCE, /data-impeccable-placeholder-resize/, 'placeholder edge handles on annotation overlay'); @@ -406,12 +471,12 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /buildInsertConfigureRow[\s\S]*?const count = el\('button', \{[\s\S]{0,320}?height: '28px'/, - 'insert count toggle must match input height', + /buildInsertConfigureRow[\s\S]*?buildConfigureCountControl\(/, + 'insert count toggle uses the same inline bar control as configure mode', ); assert.match( SOURCE, - /buildInsertConfigureRow[\s\S]*?const create = el\('button', \{[\s\S]{0,320}?height: '28px'/, + /buildInsertConfigureRow[\s\S]*?buildConfigureSubmitButton\(/, 'insert Create button must match input height', ); assert.match(SOURCE, /function resolveBarAnchor\(\)/, 'bar positions from a connected anchor'); @@ -463,6 +528,113 @@ describe('live-browser.js regression guards', () => { ); }); + it('configure row groups selection and input on the left, trailing controls before submit', () => { + assert.match( + SOURCE, + /function configureModifierPillStyle\(extra = \{\}\)[\s\S]{0,480}?background: 'transparent'[\s\S]{0,120}?color: P\.textDim/, + 'configure modifier chips use muted global-bar idle chrome, not active gold', + ); + assert.match( + SOURCE, + /const CONFIGURE_ROW_TRACK_H = '18px'/, + 'configure row shares one text track height across pills and input', + ); + assert.match( + SOURCE, + /function configureInputFieldStyle\(extra = \{\}\)[\s\S]{0,320}?height: CONFIGURE_ROW_TRACK_H/, + 'configure input uses the same track height as modifier pills', + ); + assert.match( + SOURCE, + /const CONFIGURE_PILL_PAD_Y = '3px'/, + 'modifier pills share 3px vertical padding', + ); + assert.match( + SOURCE, + /function configureInputShellStyle\(\)[\s\S]{0,200}?alignItems: 'center'[\s\S]{0,120}?padding: '0 6px 0 ' \+ CONFIGURE_BAR_INSET/, + 'configure shell vertically centers the row; inset matches centered pill margin', + ); + assert.match( + SOURCE, + /inputShell\.appendChild\(buildSelectionPill[\s\S]{0,120}?inputShell\.appendChild\(input\)/, + 'selection pill and prompt input share the left side of the bar', + ); + assert.match( + SOURCE, + /buildConfigureTrailingCluster\(\[action, count\], voiceBtn, go\)/, + 'freeform and variant count sit just left of voice and submit', + ); + }); + + it('configure bar keeps selection pill, inline controls, outline, and instant tooltips', () => { + assert.match(SOURCE, /function buildSelectionPill\(/, 'selected element tag lives in the configure bar'); + assert.match(SOURCE, /CONFIGURE_SELECTION_PILL_PAD = '1px 4px'/, 'selection pill uses 1px 4px padding'); + assert.match( + SOURCE, + /function configureSelectionPillStyle\(extra = \{\}\)[\s\S]{0,400}?color: P\.patina/, + 'selection pill label uses patina text color', + ); + assert.doesNotMatch( + SOURCE, + /function configureSelectionPillStyle\(extra = \{\}\)[\s\S]{0,400}?minHeight:/, + 'selection pill must not impose a min-height', + ); + assert.match( + SOURCE, + /gridArea: '1 \/ 1'/, + 'selection pill swaps tag and clear faces without resizing the bar', + ); + assert.match( + SOURCE, + /function shouldShowHighlightTagTooltip\(\)[\s\S]{0,160}?state !== 'CONFIGURING'/, + 'configure mode keeps the outline but drops the floating tag tooltip', + ); + assert.match(SOURCE, /function buildConfigureActionControl\(/, 'action dropdown is an inline bar control, not a pill'); + assert.match(SOURCE, /function buildConfigureCountControl\(/, 'variant count is an inline bar control, not a pill'); + assert.match( + SOURCE, + /const hiTarget = resolveBarAnchor\(\);[\s\S]{0,200}?showHighlight\(hiTarget\);/, + 'scroll tracking keeps the element outline visible while configuring', + ); + assert.match(SOURCE, /function showConfigureBarTooltip\(/, 'configure controls use instant custom tooltips'); + assert.doesNotMatch( + SOURCE, + /count\.title = controlsLocked \? 'Apply is still running' : 'Variants: click to change'/, + 'variant count must not rely on native title tooltips', + ); + }); + + it('variant count pill cycles through x1', () => { + assert.match( + SOURCE, + /function cycleSelectedCount\(\)[\s\S]{0,200}?selectedCount = VARIANT_COUNT_MIN/, + 'variant count must wrap back to x1 after the max', + ); + assert.doesNotMatch( + SOURCE, + /selectedCount >= 4 \? 2/, + 'variant count must not skip x1 by wrapping 4 back to 2', + ); + }); + + it('variant injection resolves the picked anchor before entering recovery', () => { + assert.match( + SOURCE, + /function resolveLiveInjectionAnchor\(originalMarkup\)/, + 'variant source fallback must try the live picked element before giving up on anchor resolution', + ); + assert.match( + SOURCE, + /pickedAnchor: pickedAnchorSnapshot/, + 'picked anchor snapshot must persist in session storage for resume and reinjection', + ); + assert.doesNotMatch( + SOURCE, + /showToast\('Variants ready\. Reveal the selected element to resume\.'/, + 'recovery chrome already shows this message in the generating bar; a duplicate toast stacks two bars', + ); + }); + it('editing focus timeout does not read a stale inline edit row', () => { assert.doesNotMatch( SOURCE, diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index b9e8ec085..dc62d88bc 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -93,9 +93,14 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function buildConfigureRow\(\)[\s\S]{0,80}?const controlsLocked = pendingApplyInFlight === true;[\s\S]*?go\.disabled = controlsLocked;/, + /function buildConfigureRow\(\)[\s\S]{0,80}?const controlsLocked = pendingApplyInFlight === true;[\s\S]*?const go = buildConfigureSubmitButton\(\{\s*controlsLocked,/, 'Configure controls should render disabled while manual copy edits are applying', ); + assert.match( + SOURCE, + /function buildConfigureSubmitButton\(\{ controlsLocked[\s\S]{0,700}?btn\.disabled = controlsLocked;/, + 'the configure submit button must disable itself while manual copy edits are applying', + ); assert.match( SOURCE, /function handleMouseMove\(e\) \{[\s\S]{0,80}?if \(pendingApplyInFlight\) return;/, @@ -293,7 +298,7 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /case 'error':\s*if \(pendingAcceptedSession\?\.id && msg\.id === pendingAcceptedSession\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?state = 'CYCLING';[\s\S]{0,80}?updateBarContent\('cycling'\);[\s\S]{0,160}?break;/, + /case 'error':\s*if \(pendingAcceptedSession\?\.id && msg\.id === pendingAcceptedSession\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?setLiveState\('CYCLING'\);[\s\S]{0,80}?updateBarContent\('cycling'\);[\s\S]{0,160}?break;/, 'an SSE error for a queued accept should invalidate pending accept state and keep variants retryable', ); assert.equal( diff --git a/tests/live-e2e/ui.mjs b/tests/live-e2e/ui.mjs index dc533840f..84d332091 100644 --- a/tests/live-e2e/ui.mjs +++ b/tests/live-e2e/ui.mjs @@ -5,7 +5,8 @@ * Selector strategy: live-browser.js uses deterministic ids (`impeccable-live-*`) * for the global bar, per-element bar, action picker, and params panel. Buttons * inside the per-element bar are matched by visible text or unicode glyph - * (`Go →`, `← / →`, `✓ Accept`, `✕`). All selectors below come from + * (`← / →`, `✓ Accept`, `✕`), or by aria-label for icon-only buttons (the + * configure submit button). All selectors below come from * skill/scripts/live-browser.js — keep this file in sync if * the bar's text content changes. */ @@ -18,6 +19,9 @@ const GLOBAL_BAR_ID = '#impeccable-live-global-bar'; const PICKER_ID = '#impeccable-live-picker'; const EDIT_BADGE_ID = '#impeccable-live-edit-badge'; const PENDING_DOCK_ID = '#impeccable-live-pending-dock'; +// The configure-row submit button is icon-only; its accessible name is the +// only stable handle (see buildConfigureSubmitButton in live-browser.js). +const GO_BUTTON_ARIA_LABEL = 'Generate variants'; const STEER_CHAT_ID = '#impeccable-live-page-chat'; const STEER_INPUT_ID = '#impeccable-live-page-chat-input'; const PICK_TOGGLE = '#impeccable-live-pick-toggle'; @@ -423,20 +427,21 @@ export async function pickElement(page, selector, opts = {}) { await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 }); } } - // Wait specifically for the Configure-row Go button to be in the bar. + // Wait specifically for the Configure-row submit button to be in the bar. // pickElement returning before that race-conditions with clickGo on // fixtures whose framework re-renders right after pick (modal open, tab - // switch). Anchoring the wait on the Go button's text is robust: the bar - // can be visible-but-empty (state=PICKING) before showBar('configure') - // populates the row. + // switch). Anchoring the wait on the submit button's accessible name is + // robust: the bar can be visible-but-empty (state=PICKING) before + // showBar('configure') populates the row, and the button itself is + // icon-only. await page.waitForFunction( - (barSel) => { + ({ barSel, goLabel }) => { const bar = window.__impeccableLiveQuery(barSel); if (!bar) return false; const btns = [...bar.querySelectorAll('button')]; - return btns.some((b) => /Go\b/.test(b.textContent || '')); + return btns.some((b) => (b.getAttribute('aria-label') || '') === goLabel); }, - BAR_ID, + { barSel: BAR_ID, goLabel: GO_BUTTON_ARIA_LABEL }, { timeout: 5_000 }, ); } @@ -532,17 +537,17 @@ export async function setCount(page, count) { export async function clickGo(page) { let lastErr; for (let attempt = 0; attempt < 3; attempt++) { - await clickBarButton(page, /Go\b/); + await clickBarButton(page, { ariaLabel: GO_BUTTON_ARIA_LABEL }); const advanced = await page.waitForFunction( - (barSel) => { + ({ barSel, goLabel }) => { const bar = window.__impeccableLiveQuery(barSel); if (!bar) return false; const text = bar.textContent || ''; if (/Generating\b/.test(text)) return true; if (/\d+\s*\/\s*\d+/.test(text)) return true; - return ![...bar.querySelectorAll('button')].some((button) => /Go\b/.test(button.textContent || '')); + return ![...bar.querySelectorAll('button')].some((button) => (button.getAttribute('aria-label') || '') === goLabel); }, - BAR_ID, + { barSel: BAR_ID, goLabel: GO_BUTTON_ARIA_LABEL }, { timeout: 3_000 }, ).then(() => true, (err) => { lastErr = err; @@ -616,15 +621,21 @@ export async function clickPrev(page) { await clickBarButton(page, '←'); } +function barButtonMatch(label) { + if (label instanceof RegExp) return { kind: 'regex', source: label.source, flags: label.flags }; + if (label && typeof label === 'object' && label.ariaLabel) return { kind: 'aria', value: label.ariaLabel }; + return { kind: 'text', value: String(label) }; +} + async function clickBarButton(page, label) { - const textMatch = label instanceof RegExp - ? { kind: 'regex', source: label.source, flags: label.flags } - : { kind: 'text', value: String(label) }; + const textMatch = barButtonMatch(label); let lastErr; for (let attempt = 0; attempt < 3; attempt++) { try { await installLiveQueryHelpers(page); - const button = page.locator(`${BAR_ID} button`, { hasText: label }); + const button = textMatch.kind === 'aria' + ? page.locator(`${BAR_ID} button[aria-label="${textMatch.value}"]`) + : page.locator(`${BAR_ID} button`, { hasText: label }); await button.click({ timeout: 5_000 }); return; } catch (err) { @@ -651,9 +662,7 @@ async function clickBarButton(page, label) { async function dispatchBarButton(page, label) { try { await installLiveQueryHelpers(page); - const textMatch = label instanceof RegExp - ? { kind: 'regex', source: label.source, flags: label.flags } - : { kind: 'text', value: String(label) }; + const textMatch = barButtonMatch(label); return await withTimeout( page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch }), 5_000, @@ -671,6 +680,7 @@ function findAndClickBarButton({ barSel, textMatch }) { .find((candidate) => { const text = candidate.textContent || ''; if (textMatch.kind === 'regex') return new RegExp(textMatch.source, textMatch.flags).test(text); + if (textMatch.kind === 'aria') return (candidate.getAttribute('aria-label') || '') === textMatch.value; return text.includes(textMatch.value); }); if (!btn) return false; @@ -779,7 +789,9 @@ export async function clickSaveEdit(page) { async function clickEditBadgeButton(page, label) { const proxyRect = await page.evaluate((text) => { const proxies = [...document.querySelectorAll('[data-impeccable-edit-badge-proxy="true"]')]; - const proxy = proxies.find((candidate) => (candidate.title || '').includes(text)); + const proxy = proxies.find((candidate) => + (candidate.title || candidate.getAttribute('aria-label') || '').includes(text) + ); if (!proxy) return null; const rect = proxy.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) return null; @@ -797,7 +809,7 @@ async function clickEditBadgeButton(page, label) { const clicked = await page.evaluate(({ badgeSel, text }) => { const badge = window.__impeccableLiveQuery(badgeSel); const btn = [...(badge?.querySelectorAll('button') || [])].find((candidate) => - (candidate.textContent || '').includes(text) + (candidate.textContent || candidate.getAttribute('aria-label') || candidate.title || '').includes(text) ); if (!btn) return false; btn.click(); diff --git a/tests/live-svelte-adapter-deepseek.test.mjs b/tests/live-svelte-adapter-deepseek.test.mjs index 59441d8a6..786ad1441 100644 --- a/tests/live-svelte-adapter-deepseek.test.mjs +++ b/tests/live-svelte-adapter-deepseek.test.mjs @@ -439,6 +439,7 @@ async function assertSelectedElementChrome(page) { hasPicker: Boolean(picker), hasBadge: Boolean(badge), badgeText: badge?.textContent || '', + badgeLabel: badge?.querySelector('button')?.getAttribute('aria-label') || badge?.title || '', hasHighlight: Boolean(highlight), highlightVisible: highlight?.style.display !== 'none', hasInput: Boolean(input), @@ -449,7 +450,7 @@ async function assertSelectedElementChrome(page) { assert.equal(result.hasBar, true); assert.equal(result.hasPicker, true); assert.equal(result.hasBadge, true); - assert.match(result.badgeText, /Edit copy/); + assert.match(result.badgeLabel, /Edit copy/); assert.equal(result.hasHighlight, true); assert.equal(result.highlightVisible, true); assert.equal(result.hasInput, true);