From 5b5e487a4f973a5c646924220a9036844fb5b865 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 10 Jun 2026 22:18:45 -0700 Subject: [PATCH] Improve live mode configure bar and pick UX (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: tear down annotation overlay when Escape exits live pick mode. The configure prompt auto-focuses and bypasses the global Escape handler, so its local path must hide the annot overlay; togglePick off now does the same as a safety net. Co-authored-by: Cursor * Improve live mode steer pill typing affordance. Show a visible caret and placeholder when focused, expand on pointerdown, and drop the muddy border so the graphite surface carries the affordance alone. Co-authored-by: Cursor * Improve live mode configure bar layout and pill styling. Align pills and input on a shared text track, refine muted pill chrome with a quiet action border, and center the row with symmetric inset so spacing reads evenly in the 36px bar. Co-authored-by: Cursor * Add x1 to live mode variant count picker. The configure bar count pill now cycles 1→2→3→4→1 so users can request a single variant. Co-authored-by: Cursor * Polish live mode configure bar, edit badge, and action picker. Refine selection pill layout and tooltips, shrink edit copy to an icon aligned with the outline, right-align the action picker, and sync demo styles and regression coverage. Co-authored-by: Cursor * Fix live mode element nav when configure input is focused. Passthrough empty arrow keys from the configure and steer prompts so handleKeyDown can move between pickable elements without breaking autofocus typing. Co-authored-by: Cursor * Remove accidental live.js inject from Base.astro. Strip the localhost helper script tag left over from local live mode iteration so the PR ships only intentional UI changes. Co-authored-by: Cursor * Fix review findings: pick-cursor state sync, anchor recovery, e2e selectors. Code review of this branch surfaced ten confirmed bugs plus three smaller ones; this commit fixes all of them. - Route every interaction-state transition through a new setLiveState() helper that re-syncs the pick-mode crosshair, fixing four confirmed cursor bugs: never appearing on pick toggle (sync ran before the state change), sticking through the configure phase, surviving teardown page-wide, and the style mounting inside the adapter's shadow root where it can't match the host document (now document.head). - Anchor recovery: a matching id is decisive again (hashed class names and component tags broke recovery), empty-text elements can no longer match the fuzzy text passes (".includes('')" hole plus shortest-text preference), and the dead 2-class-subset fallback is removed. - Selection pill: drop the hover-only "armed" guard so keyboard activation works; the pill arms on focus as well as hover. - Configure chrome: remove the configure-bar tooltip on teardown, align restorePickerBarChrome padding with initBar (5px), share the configure-input stylesheet with the insert row, and sync the ui-core.mjs surface inventory with live-browser.js. - Site demos: delete the stale duplicate .live-demo-ctx-selection rule that killed the teal pill on dark pages, and keep the configure-phase demo bar on the overlay's dark surface in light mode so the near-white prompt text stays readable. - E2E/contract tests: match the icon-only submit button by aria-label ("Generate variants") instead of the removed "Go" text, and update source-contract pins for setLiveState and buildConfigureSubmitButton. Verified: bun run test green, live-mode E2E 23/23 across all fixtures. Co-Authored-By: Claude Fable 5 * Wire insert voice button into syncVoiceUi listening state. Voice on the insert configure row runs through the same 'configure' mode, but syncVoiceUi only stamped data-listening/aria state on the replace row's #impeccable-live-configure-voice, so the insert button never pulsed while listening. Target whichever of the two row buttons is mounted, the same either-row pattern syncConfigureInputChrome uses. Addresses Bugbot review comment on PR #242. Co-Authored-By: Claude Fable 5 * Reinject from source when the session wrapper lands during anchor recovery. The anchor-recovery observer stood down as soon as the session's variant wrapper appeared in the DOM, without running injectVariantsFromSource. A wrapper can land incomplete (wrap HMR landed, variant insert did not), which is exactly the case injectVariantsFromSource's existing-wrapper replace path handles - so recovery ended with the bar stuck and no variants. Route both the anchor-found and wrapper-landed cases through injectVariantsFromSource, which owns wrapper replacement, recovery-flag clearing, and variant display. Addresses Bugbot review comment on PR #242. Co-Authored-By: Claude Fable 5 * Restore inline edit drafts before configure chrome teardown disables editing. teardownConfigureChrome called disableInlineEdit() ahead of hideBar(), wiping inlineEditRows and the impeccableOriginalText metadata that hideBar()'s EDITING-state restoreInlineEditDrafts() needs - so turning Pick off mid "Edit copy" left edited DOM text in place, neither saved nor canceled. Let hideBar() own the sequence: it restores drafts first, then disables inline edit. Addresses Bugbot review comment on PR #242. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Cursor Co-authored-by: Claude Fable 5 --- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- .../skills/impeccable/scripts/live-browser.js | 1439 ++++++++++++----- .../impeccable/scripts/live/ui-core.mjs | 3 +- site/pages/index.astro | 53 +- site/pages/live-mode/index.astro | 32 +- site/styles/kinpaku-kit.css | 183 ++- site/styles/light-mode.css | 57 +- site/styles/live-mode.css | 171 +- skill/scripts/live-browser.js | 1439 ++++++++++++----- skill/scripts/live/ui-core.mjs | 3 +- tests/live-browser-regression.test.mjs | 186 ++- tests/live-browser-source.test.mjs | 9 +- tests/live-e2e/ui.mjs | 54 +- tests/live-svelte-adapter-deepseek.test.mjs | 3 +- 17 files changed, 4849 insertions(+), 1667 deletions(-) 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);