diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index 412a2e7e..0998b0e8 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .agents/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .agents/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .agents/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.agents/skills/impeccable/scripts/impeccable-paths.mjs b/.agents/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.agents/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.agents/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.agents/skills/impeccable/scripts/live-accept.mjs b/.agents/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.agents/skills/impeccable/scripts/live-accept.mjs +++ b/.agents/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.agents/skills/impeccable/scripts/live-edit.mjs b/.agents/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.agents/skills/impeccable/scripts/live-text-rows.js b/.agents/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.agents/skills/impeccable/scripts/live-wrap.mjs b/.agents/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.agents/skills/impeccable/scripts/live-wrap.mjs +++ b/.agents/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.claude/skills/impeccable/reference/live.md b/.claude/skills/impeccable/reference/live.md index 112d6f6e..6a6173e6 100644 --- a/.claude/skills/impeccable/reference/live.md +++ b/.claude/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .claude/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .claude/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .claude/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.claude/skills/impeccable/scripts/impeccable-paths.mjs b/.claude/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.claude/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.claude/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.claude/skills/impeccable/scripts/live-accept.mjs b/.claude/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.claude/skills/impeccable/scripts/live-accept.mjs +++ b/.claude/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.claude/skills/impeccable/scripts/live-edit.mjs b/.claude/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.claude/skills/impeccable/scripts/live-text-rows.js b/.claude/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.claude/skills/impeccable/scripts/live-wrap.mjs b/.claude/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.claude/skills/impeccable/scripts/live-wrap.mjs +++ b/.claude/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.cursor/skills/impeccable/reference/live.md b/.cursor/skills/impeccable/reference/live.md index 681a7caa..df4daa5d 100644 --- a/.cursor/skills/impeccable/reference/live.md +++ b/.cursor/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .cursor/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .cursor/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .cursor/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.cursor/skills/impeccable/scripts/impeccable-paths.mjs b/.cursor/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.cursor/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.cursor/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.cursor/skills/impeccable/scripts/live-accept.mjs b/.cursor/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.cursor/skills/impeccable/scripts/live-accept.mjs +++ b/.cursor/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.cursor/skills/impeccable/scripts/live-edit.mjs b/.cursor/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.cursor/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.cursor/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.cursor/skills/impeccable/scripts/live-text-rows.js b/.cursor/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.cursor/skills/impeccable/scripts/live-wrap.mjs b/.cursor/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.cursor/skills/impeccable/scripts/live-wrap.mjs +++ b/.cursor/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.gemini/skills/impeccable/reference/live.md b/.gemini/skills/impeccable/reference/live.md index 6a71a250..12d6c114 100644 --- a/.gemini/skills/impeccable/reference/live.md +++ b/.gemini/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .gemini/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .gemini/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .gemini/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.gemini/skills/impeccable/scripts/impeccable-paths.mjs b/.gemini/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.gemini/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.gemini/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.gemini/skills/impeccable/scripts/live-accept.mjs b/.gemini/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.gemini/skills/impeccable/scripts/live-accept.mjs +++ b/.gemini/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.gemini/skills/impeccable/scripts/live-edit.mjs b/.gemini/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.gemini/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.gemini/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.gemini/skills/impeccable/scripts/live-text-rows.js b/.gemini/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.gemini/skills/impeccable/scripts/live-wrap.mjs b/.gemini/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.gemini/skills/impeccable/scripts/live-wrap.mjs +++ b/.gemini/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.github/skills/impeccable/reference/live.md b/.github/skills/impeccable/reference/live.md index 0d92cd5c..c54098a6 100644 --- a/.github/skills/impeccable/reference/live.md +++ b/.github/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .github/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .github/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .github/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.github/skills/impeccable/scripts/impeccable-paths.mjs b/.github/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.github/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.github/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.github/skills/impeccable/scripts/live-accept.mjs b/.github/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.github/skills/impeccable/scripts/live-accept.mjs +++ b/.github/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.github/skills/impeccable/scripts/live-edit.mjs b/.github/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.github/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.github/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.github/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.github/skills/impeccable/scripts/live-text-rows.js b/.github/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.github/skills/impeccable/scripts/live-wrap.mjs b/.github/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.github/skills/impeccable/scripts/live-wrap.mjs +++ b/.github/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.gitignore b/.gitignore index 1bd57c6b..87174e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ Thumbs.db .impeccable/live/sessions/ .impeccable/live/annotations/ .impeccable/live/cache/ +.impeccable/live/pending-manual-edits.json .impeccable/history/ # Per-run critique snapshots are local artifacts. ignore.md (also under # this dir) carries deferrals the user may want to share, so it's @@ -103,3 +104,4 @@ site/public/js/generated/ !.codex/agents/ !.codex/agents/** .astro/ +.claude/settings.json diff --git a/.kiro/skills/impeccable/reference/live.md b/.kiro/skills/impeccable/reference/live.md index 06c5bd9a..d44705f1 100644 --- a/.kiro/skills/impeccable/reference/live.md +++ b/.kiro/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .kiro/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .kiro/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .kiro/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.kiro/skills/impeccable/scripts/impeccable-paths.mjs b/.kiro/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.kiro/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.kiro/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.kiro/skills/impeccable/scripts/live-accept.mjs b/.kiro/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.kiro/skills/impeccable/scripts/live-accept.mjs +++ b/.kiro/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.kiro/skills/impeccable/scripts/live-edit.mjs b/.kiro/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.kiro/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.kiro/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.kiro/skills/impeccable/scripts/live-text-rows.js b/.kiro/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.kiro/skills/impeccable/scripts/live-wrap.mjs b/.kiro/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.kiro/skills/impeccable/scripts/live-wrap.mjs +++ b/.kiro/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.opencode/skills/impeccable/reference/live.md b/.opencode/skills/impeccable/reference/live.md index 2447e03f..7d01dbc6 100644 --- a/.opencode/skills/impeccable/reference/live.md +++ b/.opencode/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .opencode/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .opencode/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .opencode/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.opencode/skills/impeccable/scripts/impeccable-paths.mjs b/.opencode/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.opencode/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.opencode/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.opencode/skills/impeccable/scripts/live-accept.mjs b/.opencode/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.opencode/skills/impeccable/scripts/live-accept.mjs +++ b/.opencode/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.opencode/skills/impeccable/scripts/live-edit.mjs b/.opencode/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.opencode/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.opencode/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.opencode/skills/impeccable/scripts/live-text-rows.js b/.opencode/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.opencode/skills/impeccable/scripts/live-wrap.mjs b/.opencode/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.opencode/skills/impeccable/scripts/live-wrap.mjs +++ b/.opencode/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.pi/skills/impeccable/reference/live.md b/.pi/skills/impeccable/reference/live.md index 50ce9e2b..fe6d3ab1 100644 --- a/.pi/skills/impeccable/reference/live.md +++ b/.pi/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .pi/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .pi/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .pi/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .pi/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .pi/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.pi/skills/impeccable/scripts/impeccable-paths.mjs b/.pi/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.pi/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.pi/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.pi/skills/impeccable/scripts/live-accept.mjs b/.pi/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.pi/skills/impeccable/scripts/live-accept.mjs +++ b/.pi/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.pi/skills/impeccable/scripts/live-edit.mjs b/.pi/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.pi/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.pi/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.pi/skills/impeccable/scripts/live-text-rows.js b/.pi/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.pi/skills/impeccable/scripts/live-wrap.mjs b/.pi/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.pi/skills/impeccable/scripts/live-wrap.mjs +++ b/.pi/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.qoder/skills/impeccable/reference/live.md b/.qoder/skills/impeccable/reference/live.md index bad4583f..46705715 100644 --- a/.qoder/skills/impeccable/reference/live.md +++ b/.qoder/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .qoder/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .qoder/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .qoder/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.qoder/skills/impeccable/scripts/impeccable-paths.mjs b/.qoder/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.qoder/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.qoder/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.qoder/skills/impeccable/scripts/live-accept.mjs b/.qoder/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.qoder/skills/impeccable/scripts/live-accept.mjs +++ b/.qoder/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.qoder/skills/impeccable/scripts/live-edit.mjs b/.qoder/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.qoder/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.qoder/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.qoder/skills/impeccable/scripts/live-poll.mjs b/.qoder/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.qoder/skills/impeccable/scripts/live-poll.mjs +++ b/.qoder/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.qoder/skills/impeccable/scripts/live-text-rows.js b/.qoder/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.qoder/skills/impeccable/scripts/live-wrap.mjs b/.qoder/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.qoder/skills/impeccable/scripts/live-wrap.mjs +++ b/.qoder/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.rovodev/skills/impeccable/reference/live.md b/.rovodev/skills/impeccable/reference/live.md index a7ddf0f3..58ad68af 100644 --- a/.rovodev/skills/impeccable/reference/live.md +++ b/.rovodev/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .rovodev/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .rovodev/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .rovodev/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs b/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.rovodev/skills/impeccable/scripts/live-accept.mjs b/.rovodev/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.rovodev/skills/impeccable/scripts/live-accept.mjs +++ b/.rovodev/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.rovodev/skills/impeccable/scripts/live-edit.mjs b/.rovodev/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.rovodev/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.rovodev/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.rovodev/skills/impeccable/scripts/live-text-rows.js b/.rovodev/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.rovodev/skills/impeccable/scripts/live-wrap.mjs b/.rovodev/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.rovodev/skills/impeccable/scripts/live-wrap.mjs +++ b/.rovodev/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.trae-cn/skills/impeccable/reference/live.md b/.trae-cn/skills/impeccable/reference/live.md index a1c8aa5a..f9dcec2f 100644 --- a/.trae-cn/skills/impeccable/reference/live.md +++ b/.trae-cn/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .trae-cn/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .trae-cn/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .trae-cn/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs b/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.trae-cn/skills/impeccable/scripts/live-accept.mjs b/.trae-cn/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.trae-cn/skills/impeccable/scripts/live-accept.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.trae-cn/skills/impeccable/scripts/live-edit.mjs b/.trae-cn/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.trae-cn/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.trae-cn/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.trae-cn/skills/impeccable/scripts/live-text-rows.js b/.trae-cn/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/.trae/skills/impeccable/reference/live.md b/.trae/skills/impeccable/reference/live.md index 37bb36c1..fc93f27d 100644 --- a/.trae/skills/impeccable/reference/live.md +++ b/.trae/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .trae/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .trae/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .trae/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .trae/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .trae/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/.trae/skills/impeccable/scripts/impeccable-paths.mjs b/.trae/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/.trae/skills/impeccable/scripts/impeccable-paths.mjs +++ b/.trae/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/.trae/skills/impeccable/scripts/live-accept.mjs b/.trae/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/.trae/skills/impeccable/scripts/live-accept.mjs +++ b/.trae/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/.trae/skills/impeccable/scripts/live-edit.mjs b/.trae/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.trae/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.trae/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/.trae/skills/impeccable/scripts/live-text-rows.js b/.trae/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.trae/skills/impeccable/scripts/live-wrap.mjs b/.trae/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/.trae/skills/impeccable/scripts/live-wrap.mjs +++ b/.trae/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/package.json b/package.json index cb2336fc..c9554c36 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "dev": "npx astro dev", "preview": "bun run build && npx astro preview", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/impeccable-paths.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-reference.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs", + "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/impeccable-paths.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-reference.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-edit.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/live-text-rows.test.mjs && node --test tests/framework-fixtures.test.mjs", "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", "audit": "bun audit --audit-level=moderate", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", diff --git a/plugin/skills/impeccable/reference/live.md b/plugin/skills/impeccable/reference/live.md index 112d6f6e..6a6173e6 100644 --- a/plugin/skills/impeccable/reference/live.md +++ b/plugin/skills/impeccable/reference/live.md @@ -43,12 +43,12 @@ LOOP: node .claude/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node .claude/skills/impeccable/scripts/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node .claude/skills/impeccable/scripts/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/plugin/skills/impeccable/scripts/impeccable-paths.mjs b/plugin/skills/impeccable/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/plugin/skills/impeccable/scripts/impeccable-paths.mjs +++ b/plugin/skills/impeccable/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/plugin/skills/impeccable/scripts/live-accept.mjs b/plugin/skills/impeccable/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/plugin/skills/impeccable/scripts/live-accept.mjs +++ b/plugin/skills/impeccable/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs b/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs new file mode 100644 index 00000000..98c54011 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs b/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs new file mode 100644 index 00000000..9877cacc --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/plugin/skills/impeccable/scripts/live-edit.mjs b/plugin/skills/impeccable/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/plugin/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/plugin/skills/impeccable/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/plugin/skills/impeccable/scripts/live-text-rows.js b/plugin/skills/impeccable/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/plugin/skills/impeccable/scripts/live-wrap.mjs b/plugin/skills/impeccable/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/plugin/skills/impeccable/scripts/live-wrap.mjs +++ b/plugin/skills/impeccable/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/skill/reference/live.md b/skill/reference/live.md index 971dda22..46936cac 100644 --- a/skill/reference/live.md +++ b/skill/reference/live.md @@ -43,12 +43,12 @@ LOOP: node {{scripts_path}}/live-poll.mjs # default long timeout; no --timeout= Read JSON; dispatch on "type" - "generate" → Handle Generate; reply done; LOOP - "accept" → Handle Accept; complete carbonize cleanup if required; LOOP - "discard" → Handle Discard; LOOP - "prefetch" → Handle Prefetch; LOOP - "timeout" → LOOP - "exit" → break → Cleanup + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; complete carbonize cleanup if required; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup ``` ## Recovery commands @@ -93,7 +93,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" +node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" --page-url "/path" ``` Flag mapping. Keep them separate, don't collapse into `--query`: @@ -102,6 +102,7 @@ Flag mapping. Keep them separate, don't collapse into `--query`: - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` - `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. +- `--page-url` ← `event.pageUrl`. **Required when the manual-edit buffer has any pending entries** (`live-wrap.mjs` will exit with `missing_page_url_with_pending_edits` otherwise). Scopes the buffer-aware "original" content step to edits made on this page so the wrap block's `data-impeccable-variant="original"` reflects the user's staged DOM, not the un-edited source. Pass it every call: the buffer state can change between events, and forgetting it produces variants that look correct in source but ignore the user's pending changes. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups. @@ -394,6 +395,57 @@ Then remove the temporary wrapper from the served file if it's still there. Remove the wrapper you inserted in Step 2. Nothing else to do. +## Manual edits: stashed server-side; commit on user request + +When the user clicks Save in the live overlay, the browser POSTs to `/manual-edit-stash`. The server appends to `.impeccable/live/pending-manual-edits.json`. **No source file is touched. No HMR refresh.** The event is never enqueued and never reaches the poll loop. You do not see manual-edit traffic until the user explicitly asks you to commit. + +The user's edited DOM state becomes the "current truth" for downstream operations. `live-wrap.mjs` is buffer-aware: when it wraps an element that has a pending manual edit, the wrap block's `data-impeccable-variant="original"` content reflects the edited text, not the raw source. `live-accept.mjs` scrubs matching buffer entries after a successful accept (the accept embodies the manual edit, so the pending op is consumed, not lost). Variant **discard** does NOT touch the buffer; the manual edit is preserved. + +### When to commit + +Run `live-commit-manual-edits.mjs` ONLY when the user clearly asks to commit, apply, or flush pending manual edits. Examples: + +- "commit my edits" +- "apply the manual edits" +- "flush pending" +- "save those changes" (when context makes it about the manual edits, not unrelated files) + +Do NOT trigger on generic "save" mentions about unrelated files or work. + +``` +node {{scripts_path}}/live-commit-manual-edits.mjs +``` + +Optional `--page-url=` to scope. + +Output JSON: `{ applied, failed, files, cleared, reason? }`. + +- `cleared: true`: all ops succeeded. Acknowledge in one short line: "Committed N edits across M files." +- `cleared: false, reason: "no_pending_edits"`: buffer was empty. Say "Nothing to commit." +- `cleared: false` with failed entries: surface the failures and reasons. Failed ops stay in the buffer; user can fix source manually and retry, or discard. Common reasons: + - `text_not_in_source`: `originalText` not found in the matched element's source range. + - `text_ambiguous_in_block`: `originalText` appears more than once in the matched element block; the locator can't tell which leaf to update. Ask the user to rephrase one of the duplicates, then retry. + - `element_ambiguous` / `element_not_found`: locator failed. + - `invalid_chars_in_newText`: `newText` contained `<`, `>`, `{`, `}`, or a backtick. The manual-edit flow is plain-text only. If the user wants to insert markup, do it yourself with the Edit tool against the source file. + +### When to discard + +Run `live-discard-manual-edits.mjs` when the user asks to discard, throw away, or clear pending manual edits. Examples: + +- "discard the pending edits" +- "throw away my unsaved manual edits" +- "clear the staging buffer" + +``` +node {{scripts_path}}/live-discard-manual-edits.mjs +``` + +Optional `--page-url=` to scope. Output: `{ discarded, totalCount }`. + +### Do NOT auto-commit + +Do not run commit on session start, on idle, or as a side effect of any other event. Only on explicit user request. The buffer can hold edits across sessions indefinitely; that is intended. + ## Handle `accept` Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated. diff --git a/skill/scripts/impeccable-paths.mjs b/skill/scripts/impeccable-paths.mjs index 6befa9cd..0c635f62 100644 --- a/skill/scripts/impeccable-paths.mjs +++ b/skill/scripts/impeccable-paths.mjs @@ -64,7 +64,16 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) { export function readLiveServerInfo(cwd = process.cwd()) { for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) { try { - return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath }; + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (info && typeof info.pid === 'number') { + try { + process.kill(info.pid, 0); + } catch { + try { fs.unlinkSync(filePath); } catch {} + continue; + } + } + return { info, path: filePath }; } catch { /* try next */ } diff --git a/skill/scripts/live-accept.mjs b/skill/scripts/live-accept.mjs index f3cb1b48..e71dd252 100644 --- a/skill/scripts/live-accept.mjs +++ b/skill/scripts/live-accept.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -92,10 +93,47 @@ Output (JSON): if (result.carbonize) { result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; } + // Scrub stash entries whose originalText was inside the just-replaced + // wrap block. The accept embodies those manual edits (wrap was buffer- + // aware), so the pending ops are now redundant. Bounded to one file read. + if (result.handled !== false) { + try { + scrubManualEditsAgainstFile(targetFile); + } catch { + // Non-fatal; the buffer stays as-is and the user can discard later. + } + } console.log(JSON.stringify({ handled: true, file: relFile, ...result })); } } +/** + * After a variant accept rewrites a portion of targetFile, drop buffer ops + * whose originalText no longer appears in that file. Those ops were almost + * certainly inside the replaced wrap block (which previously contained the + * manual-edited text via buffer-aware wrap), and the accept now embodies them. + * + * Ops whose originalText still appears in targetFile are left alone — they + * relate to other elements the user manually edited but didn't put through the + * variants pipeline. + */ +function scrubManualEditsAgainstFile(targetFile, cwd = process.cwd()) { + const buffer = readManualEditsBuffer(cwd); + if (buffer.entries.length === 0) return; + const fileContent = fs.readFileSync(targetFile, 'utf-8'); + let mutated = false; + for (const entry of buffer.entries) { + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => { + if (!op.originalText) return true; + return fileContent.includes(op.originalText); + }); + if (entry.ops.length !== before) mutated = true; + } + buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0); + if (mutated) writeManualEditsBuffer(cwd, buffer); +} + // --------------------------------------------------------------------------- // Discard // --------------------------------------------------------------------------- @@ -592,4 +630,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile }; diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index e67cd01e..b878b92a 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -170,6 +170,7 @@ let pickerEl = null; let toastEl = null; let scrollRaf = null; + let editBadgeEl = null; // --------------------------------------------------------------------------- // Helpers @@ -906,6 +907,7 @@ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); + disableInlineEdit(); } function updateBarContent(mode) { @@ -983,7 +985,7 @@ }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } - if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); renderEditBadge('hidden'); state = 'PICKING'; 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(); @@ -1496,6 +1498,7 @@ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code } + function getVisibleVariantEl() { if (!currentSessionId) return null; const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); @@ -1665,6 +1668,431 @@ } } + // --------------------------------------------------------------------------- + // Inline text editing — makes pure-text descendants of the picked element + // directly contenteditable. Saves to source on blur of each text leaf. + // --------------------------------------------------------------------------- + + let inlineEditRows = []; + let inlineEditDrafts = new Map(); + + // Mixed-content elements (e.g.

textxtext

) skip the row + // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct + // text-node child in a marker span so the walker emits a row for it. The + // wrappers are inline display by default and inherit styles, so the page + // shouldn't visually shift. We unwrap in disableInlineEdit. + const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 }; + function wrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const tag = rootEl.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return; + if (rootEl.hasAttribute('contenteditable')) return; + const children = Array.from(rootEl.childNodes); + const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || '')); + const hasElement = children.some((n) => n.nodeType === 1); + if (hasText && hasElement) { + for (const node of children) { + if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) { + const wrap = document.createElement('span'); + wrap.dataset.impeccableTextWrap = 'true'; + wrap.textContent = node.nodeValue; + rootEl.insertBefore(wrap, node); + rootEl.removeChild(node); + } + } + } + for (const child of Array.from(rootEl.children)) { + if (!child.dataset || !child.dataset.impeccableTextWrap) { + wrapMixedContentTextNodes(child); + } + } + } + function unwrapMixedContentTextNodes(rootEl) { + if (!rootEl || rootEl.nodeType !== 1) return; + const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]'); + for (const wrap of wraps) { + const parent = wrap.parentNode; + if (!parent) continue; + const textNode = document.createTextNode(wrap.textContent); + parent.replaceChild(textNode, wrap); + parent.normalize(); + } + } + let inlineEditRoot = null; + + function enableInlineEdit(targetEl) { + const collect = window.__IMPECCABLE_LIVE_TEXT_ROWS__?.collectEditableTextRows; + if (!collect || !targetEl) return; + inlineEditRoot = targetEl; + wrapMixedContentTextNodes(targetEl); + const rows = collect(targetEl, { isOwn: own }); + inlineEditRows = rows; + inlineEditDrafts = new Map(); + for (const row of rows) { + row.el.setAttribute('contenteditable', 'true'); + row.el.dataset.impeccableEditable = 'true'; + row.el.dataset.impeccableOriginalText = row.text; + row.el.style.userSelect = 'text'; + row.el.style.cursor = 'text'; + row.el.style.outline = 'none'; + row.el.style.webkitUserModify = 'read-write-plaintext-only'; + row.el.addEventListener('input', onInlineInput); + } + } + + function disableInlineEdit() { + for (const row of inlineEditRows) { + if (document.activeElement === row.el) row.el.blur(); + row.el.removeAttribute('contenteditable'); + delete row.el.dataset.impeccableEditable; + delete row.el.dataset.impeccableOriginalText; + row.el.style.userSelect = ''; + row.el.style.cursor = ''; + row.el.style.outline = ''; + row.el.style.webkitUserModify = ''; + row.el.removeEventListener('input', onInlineInput); + } + inlineEditRows = []; + inlineEditDrafts = new Map(); + if (inlineEditRoot) { + unwrapMixedContentTextNodes(inlineEditRoot); + inlineEditRoot = null; + } + } + + function onInlineInput(e) { + inlineEditDrafts.set(e.currentTarget, e.currentTarget.innerText); + } + + function hasTextRows(el) { + if (!el) return false; + // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one + // non-whitespace direct text-node child means we have something editable + // (mixed-content paragraphs included). Mirrors what the wrap+walk path + // will produce in enableInlineEdit. + function check(node) { + if (!node || node.nodeType !== 1) return false; + const tag = node.tagName.toLowerCase(); + if (MIXED_WRAP_SKIP[tag]) return false; + if (node !== el && own(node)) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true; + } + for (const child of node.children) { + if (check(child)) return true; + } + return false; + } + return check(el); + } + + function enterEditingMode() { + state = 'EDITING'; + hideBar(); + hideAnnotOverlay(); + renderEditBadge('editing'); + enableInlineEdit(selectedElement); + // Focus first editable element and position cursor at end + if (inlineEditRows.length > 0) { + setTimeout(() => { + const el = inlineEditRows[0].el; + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }, 50); + } + } + + function cancelEditing() { + for (const row of inlineEditRows) { + if (inlineEditDrafts.has(row.el)) { + row.el.innerText = row.el.dataset.impeccableOriginalText; + } + } + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } + + // 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. + function buildLocatorForLeaf(leafEl, fallbackEl) { + if (leafEl && (leafEl.id || leafEl.classList.length > 0)) { + return { + tag: leafEl.tagName.toLowerCase(), + elementId: leafEl.id || null, + classes: [...leafEl.classList], + }; + } + let cur = leafEl?.parentElement; + while (cur && cur !== document.body) { + if (cur.id || cur.classList.length > 0) { + return { + tag: cur.tagName.toLowerCase(), + elementId: cur.id || null, + classes: [...cur.classList], + }; + } + cur = cur.parentElement; + } + return { + tag: (fallbackEl || leafEl).tagName.toLowerCase(), + elementId: (fallbackEl || leafEl).id || null, + classes: [...((fallbackEl || leafEl).classList || [])], + }; + } + + async function applyEditing() { + const ops = []; + for (const row of inlineEditRows) { + const newText = inlineEditDrafts.get(row.el); + if (newText !== undefined && newText !== row.text) { + const locator = buildLocatorForLeaf(row.el, selectedElement); + ops.push({ + ref: row.ref, + tag: locator.tag, + elementId: locator.elementId, + classes: locator.classes, + originalText: row.text, + newText, + }); + } + } + if (ops.length === 0) { cancelEditing(); return; } + // Stash to server buffer. No source write, no HMR. The user later asks the + // AI to commit, which runs live-commit-manual-edits.mjs. + try { + const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: TOKEN, + id: id8(), + pageUrl: location.pathname, + element: extractContext(selectedElement), + ops, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const stashResult = await res.json(); + updatePendingCounter(stashResult.pendingCount || 0); + maybeShowFirstSaveToast(); + disableInlineEdit(); + state = 'CONFIGURING'; + showBar('configure'); + showAnnotOverlay(selectedElement); + renderEditBadge('idle'); + } catch (err) { + console.error('[impeccable] manual edit stash failed:', err); + // Surface the specific server reason (e.g. forbidden chars in newText) + // so the user knows to rephrase rather than retrying the same content. + const detail = String(err?.message || ''); + if (detail.includes('newText cannot contain')) { + showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); + } else { + showToast('Save failed — retry or cancel', 4000); + } + } + } + + // Pending-edits pill + trash icon — updated on Save, resumeSession, and discard. + function updatePendingCounter(currentPageCount) { + if (!pendingPillEl || !pendingTrashBtn) return; + if (!currentPageCount || currentPageCount <= 0) { + pendingPillEl.style.display = 'none'; + pendingTrashBtn.style.display = 'none'; + pendingPillEl.dataset.count = '0'; + return; + } + pendingPillEl.textContent = 'Apply ' + currentPageCount + ' staged'; + pendingPillEl.style.display = 'inline-flex'; + pendingTrashBtn.style.display = 'inline-flex'; + pendingPillEl.dataset.count = String(currentPageCount); + } + + function maybeShowFirstSaveToast() { + if (!firstSaveOfSession) return; + firstSaveOfSession = false; + showToast('Saved. Click the "staged" badge to apply, or ask the AI.', 4500); + } + + async function fetchPendingCount() { + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + ); + if (!res.ok) return; + const data = await res.json(); + updatePendingCounter(data.count || 0); + } catch (err) { + // Non-fatal; the counter stays hidden. + console.warn('[impeccable] failed to fetch pending count:', err); + } + } + + async function onPendingPillClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Apply ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' to source? The page will reload.'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.error || ('HTTP ' + res.status)); + } + const result = await res.json(); + const remaining = (result.perPage && result.perPage[location.pathname]) || 0; + updatePendingCounter(remaining); + if (result.failed && result.failed.length > 0) { + console.warn('[impeccable] some staged edits failed:', result.failed); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000); + } else { + const n = result.applied?.length || count; + showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); + } + } catch (err) { + console.error('[impeccable] commit failed:', err); + showToast('Apply failed — see console', 4000); + } + } + + async function onPendingTrashClick() { + const count = parseInt(pendingPillEl?.dataset.count || '0', 10); + if (count <= 0) return; + const ok = confirm('Discard ' + count + ' staged edit' + (count === 1 ? '' : 's') + ' on this page?'); + if (!ok) return; + try { + const res = await fetch( + 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname), + { method: 'POST' }, + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + updatePendingCounter(0); + showToast('Discarded ' + count + ' staged edit' + (count === 1 ? '' : 's'), 2500); + } catch (err) { + console.error('[impeccable] discard failed:', err); + showToast('Discard failed — see console', 4000); + } + } + + // --------------------------------------------------------------------------- + // Edit content badge — floating button at element top-right to enter EDITING mode + // --------------------------------------------------------------------------- + + function initEditBadge() { + editBadgeEl = document.createElement('div'); + editBadgeEl.id = PREFIX + '-edit-badge'; + Object.assign(editBadgeEl.style, { + position: 'fixed', + zIndex: String(Z.highlight + 1), + cursor: 'default', + display: 'none', + userSelect: 'none', + }); + document.body.appendChild(editBadgeEl); + + // Remove focus rings on edit badge buttons + contenteditable elements + if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + const s = document.createElement('style'); + s.id = PREFIX + '-edit-badge-focus-style'; + s.textContent = + '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' + + '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' + + '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' + + '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; + document.head.appendChild(s); + } + } + + function positionEditBadge() { + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + const r = selectedElement.getBoundingClientRect(); + const bw = editBadgeEl.offsetWidth; + editBadgeEl.style.top = Math.max(4, r.top - 26) + 'px'; + editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + } + + function renderEditBadge(mode) { + if (mode === 'hidden' || !editBadgeEl) { + if (editBadgeEl) editBadgeEl.style.display = 'none'; + return; + } + editBadgeEl.style.display = 'flex'; + editBadgeEl.style.alignItems = 'center'; + editBadgeEl.style.cursor = 'default'; + const ACCENT = 'oklch(60% 0.25 350)'; + const PAPER = 'oklch(98% 0 0)'; + const ASH = 'oklch(55% 0 0)'; + const MIST = 'oklch(92% 0 0)'; + const calloutStyle = (color, borderColor) => ({ + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: color, + background: PAPER, + padding: '2px 8px', + border: '1px solid ' + (borderColor || color), + borderRadius: '999px', + whiteSpace: 'nowrap', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + if (mode === 'idle' || mode === 'idle-disabled') { + const disabled = mode === 'idle-disabled'; + editBadgeEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.textContent = 'Edit'; + Object.assign(btn.style, calloutStyle(disabled ? ASH : ACCENT, disabled ? MIST : ACCENT)); + if (disabled) { + btn.style.cursor = 'not-allowed'; + btn.style.opacity = '0.55'; + btn.disabled = true; + btn.title = 'Edit is disabled while variants are generating'; + } else { + btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PAPER; }); + btn.addEventListener('mouseleave', () => { btn.style.background = PAPER; btn.style.color = ACCENT; }); + btn.onclick = enterEditingMode; + } + editBadgeEl.appendChild(btn); + } else { + // 'editing' — show Cancel + Save separated + editBadgeEl.innerHTML = ''; + editBadgeEl.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, calloutStyle(ASH, MIST)); + cancel.addEventListener('mouseenter', () => { cancel.style.background = ASH; cancel.style.color = PAPER; cancel.style.borderColor = ASH; }); + cancel.addEventListener('mouseleave', () => { cancel.style.background = PAPER; cancel.style.color = ASH; cancel.style.borderColor = MIST; }); + 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 = PAPER; }); + save.addEventListener('mouseleave', () => { save.style.background = PAPER; save.style.color = ACCENT; }); + save.onclick = applyEditing; + editBadgeEl.append(cancel, save); + } + positionEditBadge(); + } + // Decide which way the popover opens: away from the picked element. If the // bar landed below the element, popover slides DOWN from the bar's bottom. // If the bar landed above, popover slides UP from the bar's top. @@ -1899,6 +2327,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); saveSession(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); @@ -2148,6 +2577,7 @@ state = 'CYCLING'; hideShaderOverlay(); updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -2169,9 +2599,14 @@ function tick() { if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { positionBar(); + positionEditBadge(); showHighlight(selectedElement); if (tuneOpen) positionParamsPanel(); } + if (state === 'EDITING') { + positionEditBadge(); + showHighlight(selectedElement); + } if (annotActive) positionAnnotOverlay(selectedElement); // Shader overlay (via debug P toggle or generation) is repositioned // by its own branch below; debug no longer has a separate overlay. @@ -2217,6 +2652,7 @@ if (state === 'GENERATING') { state = 'CYCLING'; updateBarContent('cycling'); + disableInlineEdit(); refreshParamsPanel(); } break; @@ -2241,6 +2677,7 @@ console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); hideBar(); + renderEditBadge('hidden'); state = 'PICKING'; break; } @@ -2351,12 +2788,21 @@ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { closeTunePopover(); } - // In CONFIGURING: click outside the bar and selected element returns to PICKING - if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + // In EDITING: click outside returns to CONFIGURING (via cancelEditing), then check CONFIGURING exit + if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { + cancelEditing(); + // Fall through to check if we should also exit CONFIGURING + } + // In CONFIGURING: click outside the bar and selected element returns to PICKING. + if ( + state === 'CONFIGURING' && !own(e.target) && selectedElement + && !selectedElement.contains(e.target) + ) { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); + renderEditBadge('hidden'); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -2373,6 +2819,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); maybePrefetchPage(); maybeWarnConditionalAncestor(selectedElement); @@ -2455,10 +2902,27 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + // While a contenteditable text-leaf is focused, let the browser handle + // all keys except Escape. Escape cancels the current edit (restores + // original text) and blurs without saving, staying in CONFIGURING. + if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + const original = e.target.dataset.impeccableOriginalText; + if (original !== undefined) e.target.innerText = original; + // Programmatic innerText doesn't fire the 'input' event, so the draft + // map would otherwise hold the pre-revert value and Apply would commit + // changes the user explicitly undid. + inlineEditDrafts.delete(e.target); + e.target.blur(); + return; + } if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } + if (state === 'EDITING') { cancelEditing(); return; } + if (state === 'CONFIGURING') { disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { @@ -2494,6 +2958,7 @@ clearAnnotations(); showAnnotOverlay(selectedElement); showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); return; } @@ -2502,11 +2967,13 @@ if (state === 'PICKING') { hoveredElement = next; } else { - // CONFIGURING: re-select the new element and refresh the bar + // CONFIGURING: re-select the new element selectedElement = next; clearAnnotations(); showAnnotOverlay(next); showBar('configure'); + disableInlineEdit(); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); startScrollTracking(); } showHighlight(next); @@ -2524,6 +2991,10 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + runGenerate(); + } + + function runGenerate() { const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; @@ -2561,6 +3032,11 @@ clearAnnotations(); state = '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 + // text rows have it hidden already. + if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); showBar('generating'); saveSession(); sendCheckpoint('generate_started'); @@ -3035,6 +3511,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; }, 1800); @@ -3147,6 +3624,7 @@ void main() { selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; + renderEditBadge('hidden'); state = 'PICKING'; } @@ -3278,6 +3756,9 @@ void main() { let pickActive = true; let detectCount = 0; let detectScriptLoaded = false; + let pendingPillEl = null; + let pendingTrashBtn = null; + let firstSaveOfSession = true; // Theme-aware color palette for the global bar. We detect the page's // ambient background and invert — dark bar on light pages, light bar on @@ -3510,6 +3991,49 @@ void main() { }); inner.appendChild(designBtn); + // Pending manual edits pill + trash icon. Shown when current-page count > 0. + // Sits next to Exit so it lives in the lifecycle/affordance cluster. + pendingPillEl = el('button', { + display: 'none', + alignItems: 'center', + gap: '4px', + fontFamily: FONT, + fontSize: '0.625rem', + fontWeight: '600', + letterSpacing: '0.06em', + color: 'oklch(60% 0.25 350)', + background: 'oklch(98% 0 0)', + padding: '2px 8px', + border: '1px solid oklch(60% 0.25 350)', + borderRadius: '999px', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1)', + }); + pendingPillEl.title = 'Click to apply staged edits to source'; + pendingPillEl.addEventListener('mouseenter', () => { pendingPillEl.style.background = 'oklch(60% 0.25 350)'; pendingPillEl.style.color = 'oklch(98% 0 0)'; }); + pendingPillEl.addEventListener('mouseleave', () => { pendingPillEl.style.background = 'oklch(98% 0 0)'; pendingPillEl.style.color = 'oklch(60% 0.25 350)'; }); + pendingPillEl.addEventListener('click', onPendingPillClick); + inner.appendChild(pendingPillEl); + + pendingTrashBtn = el('button', { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + padding: '0', boxSizing: 'border-box', + width: '20px', height: '20px', borderRadius: '6px', + border: 'none', background: 'transparent', + color: 'oklch(55% 0 0)', + cursor: 'pointer', + transition: 'color 0.12s ease, background 0.12s ease', + }); + pendingTrashBtn.innerHTML = ''; + pendingTrashBtn.title = 'Discard pending edits on this page'; + pendingTrashBtn.addEventListener('mouseenter', () => { pendingTrashBtn.style.color = 'oklch(60% 0.25 350)'; pendingTrashBtn.style.background = P.exitHover; }); + pendingTrashBtn.addEventListener('mouseleave', () => { pendingTrashBtn.style.color = 'oklch(55% 0 0)'; pendingTrashBtn.style.background = 'transparent'; }); + pendingTrashBtn.addEventListener('click', onPendingTrashClick); + inner.appendChild(pendingTrashBtn); + // Thin divider before the exit button const divider = el('span', { width: '1px', height: '18px', @@ -4821,6 +5345,7 @@ void main() { function init() { try { history.scrollRestoration = 'manual'; } catch {} initHighlight(); + initEditBadge(); initAnnotOverlay(); initBar(); initActionPicker(); @@ -4832,6 +5357,9 @@ void main() { document.addEventListener('keydown', handleKeyDown, true); connectSSE(); + // Restore pending-edit counter for this page (survives HMR reload + dev restart). + fetchPendingCount(); + // Check for an active session to resume (variant wrapper already in DOM after HMR) if (!resumeSession()) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); diff --git a/skill/scripts/live-commit-manual-edits.mjs b/skill/scripts/live-commit-manual-edits.mjs new file mode 100755 index 00000000..98c54011 --- /dev/null +++ b/skill/scripts/live-commit-manual-edits.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * CLI helper: commit pending manual edits from the buffer to source. + * + * Reads .impeccable/live/pending-manual-edits.json, then for each entry shells + * out to live-edit.mjs (which handles the actual source-file rewrite). On a + * successful entry, drops it from the buffer. Failed entries stay in the + * buffer so the user can fix the underlying source mismatch and retry. + * + * Trigger: only when the user explicitly asks the AI to commit manual edits. + * Never run as a side effect of other operations. + * + * Usage: + * node live-commit-manual-edits.mjs # commit all pages + * node live-commit-manual-edits.mjs --page-url=/ # commit only entries for "/" + * + * Output JSON: + * { applied: [...], failed: [...], files: [...], cleared: bool, reason?: 'no_pending_edits' } + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readBuffer, writeBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EDIT_SCRIPT = path.join(__dirname, 'live-edit.mjs'); + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-commit-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +const buffer = readBuffer(cwd); +const entries = pageUrlFilter + ? buffer.entries.filter((e) => e.pageUrl === pageUrlFilter) + : buffer.entries; + +if (entries.length === 0) { + console.log(JSON.stringify({ + cleared: false, + reason: 'no_pending_edits', + applied: [], + failed: [], + files: [], + })); + process.exit(0); +} + +const applied = []; +const failed = []; +const filesTouched = new Set(); +const committedEntryIds = new Set(); + +for (const entry of entries) { + let result; + try { + const out = execFileSync( + 'node', + [EDIT_SCRIPT, '--id', entry.id, '--ops', JSON.stringify(entry.ops)], + { encoding: 'utf-8', cwd, timeout: 30_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + failed.push({ id: entry.id, pageUrl: entry.pageUrl, reason: 'edit_script_error', message: err.message }); + continue; + } + if (Array.isArray(result.files)) for (const f of result.files) filesTouched.add(f); + if (Array.isArray(result.applied)) for (const a of result.applied) applied.push({ ...a, pageUrl: entry.pageUrl }); + if (Array.isArray(result.failed) && result.failed.length > 0) { + for (const f of result.failed) failed.push({ ...f, id: entry.id, pageUrl: entry.pageUrl }); + // Entry has some failures: keep the failed ops in the buffer, drop the + // applied ones. We re-walk: keep ops whose ref shows up in result.failed. + const failedRefs = new Set(result.failed.map((f) => f.op?.ref).filter(Boolean)); + entry.ops = entry.ops.filter((op) => failedRefs.has(op.ref)); + if (entry.ops.length === 0) committedEntryIds.add(entry.id); + } else { + committedEntryIds.add(entry.id); + } +} + +// Rewrite the buffer: drop fully-committed entries; keep entries with +// remaining (failed) ops. +const remainingEntries = []; +for (const entry of buffer.entries) { + if (pageUrlFilter && entry.pageUrl !== pageUrlFilter) { + remainingEntries.push(entry); + continue; + } + if (committedEntryIds.has(entry.id)) continue; + // Find the (possibly mutated) entry from above to preserve failed-only ops. + const updated = entries.find((e) => e.id === entry.id) || entry; + if (updated.ops.length > 0) remainingEntries.push(updated); +} +writeBuffer(cwd, { entries: remainingEntries }); + +console.log(JSON.stringify({ + cleared: failed.length === 0, + applied, + failed, + files: [...filesTouched], +})); diff --git a/skill/scripts/live-discard-manual-edits.mjs b/skill/scripts/live-discard-manual-edits.mjs new file mode 100755 index 00000000..9877cacc --- /dev/null +++ b/skill/scripts/live-discard-manual-edits.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * CLI helper: discard pending manual edits from the buffer without applying. + * + * Reads .impeccable/live/pending-manual-edits.json, drops entries, writes back. + * No source-file writes. Use this when the user wants to throw away unsaved + * manual edits. + * + * Trigger: only when the user explicitly asks the AI to discard / throw away / + * clear pending manual edits. + * + * Usage: + * node live-discard-manual-edits.mjs # discard all pending + * node live-discard-manual-edits.mjs --page-url=/ # discard only entries for "/" + * + * Output JSON: { discarded: N, totalCount: N } + */ + +import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs'; + +function argVal(args, name) { + const prefix = name + '='; + for (const a of args) { + if (a === name) return true; + if (a.startsWith(prefix)) return a.slice(prefix.length); + } + return null; +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-discard-manual-edits.mjs [--page-url=]'); + process.exit(0); +} + +const pageUrlFilter = argVal(args, '--page-url'); +const cwd = process.cwd(); + +let discarded; +if (pageUrlFilter) { + discarded = removeEntries(cwd, (entry) => entry.pageUrl === pageUrlFilter); +} else { + discarded = truncateBuffer(cwd); +} + +const remaining = readBuffer(cwd).entries.reduce((n, e) => n + e.ops.length, 0); +console.log(JSON.stringify({ discarded, totalCount: remaining })); diff --git a/skill/scripts/live-edit.mjs b/skill/scripts/live-edit.mjs new file mode 100644 index 00000000..8d833a9f --- /dev/null +++ b/skill/scripts/live-edit.mjs @@ -0,0 +1,250 @@ +/** + * CLI helper: apply manual text edits from the Live-Bar text panel back to + * source. Auto-handled by live-poll.mjs the same way live-accept.mjs is. + * + * Usage: + * node live-edit.mjs --id ID --ops '' [--file PATH] + * + * Each op is one of: + * { ref, tag, elementId?, classes?, originalText, newText } // text replace + * { ref, tag, elementId?, classes?, originalText, deleted: true } // block delete + * + * The locator reuses live-wrap.mjs's buildSearchQueries + findFileWithQuery + + * findAllElements + filterByText. Text replace constrains to the matched + * element's source range so an `originalText` that appears elsewhere isn't + * touched. Delete uses findClosingLine and refuses (unsafe_delete) when the + * resolved close-line doesn't carry a literal `` for the expected tag. + * + * Output: JSON { ok, files, applied, failed }. live-poll prints the event with + * `_editResult` attached when failed.length > 0 so the agent can fix the + * remaining ops with the Edit tool. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isGeneratedFile } from './is-generated.mjs'; +import { + buildSearchQueries, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, +} from './live-wrap.mjs'; + +export async function editCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage: node live-edit.mjs --id ID --ops [--file PATH]'); + process.exit(0); + } + + const id = argVal(args, '--id'); + const opsRaw = argVal(args, '--ops'); + const explicitFile = argVal(args, '--file'); + + if (!id) { fatal('missing --id'); } + if (!opsRaw) { fatal('missing --ops'); } + + let ops; + try { ops = JSON.parse(opsRaw); } + catch (err) { fatal('--ops must be valid JSON: ' + err.message); } + if (!Array.isArray(ops) || ops.length === 0) fatal('--ops must be a non-empty array'); + + const cwd = process.cwd(); + const genOpts = { cwd }; + + // Group resolved ops by file so we write each file once. + const byFile = new Map(); + const failed = []; + + for (const op of ops) { + const located = resolveOp(op, explicitFile, cwd, genOpts); + if (!located.ok) { + failed.push({ ref: op.ref, op, reason: located.reason, candidates: located.candidates }); + continue; + } + const { file, match } = located; + if (!byFile.has(file)) { + byFile.set(file, { content: fs.readFileSync(file, 'utf-8'), ops: [] }); + } + byFile.get(file).ops.push({ op, match }); + } + + const applied = []; + const filesWritten = []; + + for (const [file, state] of byFile) { + // Apply bottom-up so earlier line indices remain valid as content above is + // unchanged. We re-derive `lines` after each op since content shifts. + state.ops.sort((a, b) => b.match.startLine - a.match.startLine); + let content = state.content; + let changed = false; + for (const { op, match } of state.ops) { + // Re-resolve match coordinates against the current content state. The + // initial match came from the original buffer; for bottom-up application, + // those indices are still valid for ops above the previous edit, but a + // safer move is to recompute lines on the current content. + const lines = content.split('\n'); + const adjusted = adjustMatch(lines, op, match); + if (!adjusted) { + failed.push({ ref: op.ref, op, reason: 'lost_after_prior_op', file }); + continue; + } + const result = op.deleted === true + ? applyDelete(content, lines, op, adjusted) + : applyTextReplace(content, lines, op, adjusted); + if (!result.ok) { + const failEntry = { ref: op.ref, op, reason: result.reason, file }; + if (result.forbidden) failEntry.forbidden = result.forbidden; + if (result.occurrences) failEntry.occurrences = result.occurrences; + failed.push(failEntry); + continue; + } + content = result.content; + changed = true; + applied.push({ ref: op.ref, op, file: path.relative(cwd, file), line: adjusted.startLine + 1 }); + } + if (changed) { + fs.writeFileSync(file, content); + filesWritten.push(path.relative(cwd, file)); + } + } + + console.log(JSON.stringify({ ok: true, files: filesWritten, applied, failed })); +} + +function resolveOp(op, explicitFile, cwd, genOpts) { + if (!op || typeof op !== 'object') return { ok: false, reason: 'invalid_op' }; + if (!op.tag) return { ok: false, reason: 'missing_tag' }; + if (typeof op.originalText !== 'string') return { ok: false, reason: 'missing_originalText' }; + + const elementId = op.elementId || null; + const classes = Array.isArray(op.classes) ? op.classes.join(',') : (op.classes || null); + if (!elementId && !classes) { + // Tag alone is too broad to disambiguate safely. + return { ok: false, reason: 'insufficient_locator' }; + } + + const queries = buildSearchQueries(elementId, classes, op.tag, null); + + let targetFile = explicitFile; + if (!targetFile) { + for (const q of queries) { + targetFile = findFileWithQuery(q, cwd, genOpts); + if (targetFile) break; + } + if (!targetFile) return { ok: false, reason: 'element_not_found' }; + } + + if (isGeneratedFile(targetFile, genOpts)) { + return { ok: false, reason: 'file_is_generated' }; + } + + const content = fs.readFileSync(targetFile, 'utf-8'); + const lines = content.split('\n'); + + const candidates = []; + for (const q of queries) { + const all = findAllElements(lines, q, op.tag); + for (const c of all) { + if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c); + } + if (candidates.length === 1) break; + } + if (candidates.length === 0) return { ok: false, reason: 'element_not_found' }; + + let match; + if (candidates.length === 1) { + match = candidates[0]; + } else { + const filtered = filterByText(candidates, lines, op.originalText); + if (filtered.length === 1) match = filtered[0]; + else if (filtered.length === 0) match = candidates[0]; // dynamic/templated text — fall back + else return { + ok: false, + reason: 'element_ambiguous', + candidates: filtered.map((c) => ({ startLine: c.startLine + 1, endLine: c.endLine + 1 })), + }; + } + + return { ok: true, file: targetFile, match }; +} + +// After a higher-up op rewrites part of the file, the unchanged ranges below it +// still have stable line indices (we sort ops bottom-up). But text-replace +// inside the same element block can shift the endLine. Keep the original +// startLine and recompute endLine from the live content. +function adjustMatch(lines, op, match) { + const { startLine } = match; + if (startLine >= lines.length) return null; + // Verify the opening line still references the expected tag. + const opener = lines[startLine].match(new RegExp('<' + op.tag + '(?=[\\s/>]|$)')); + if (!opener) return null; + return { startLine, endLine: findClosingLine(lines, startLine) }; +} + +function applyTextReplace(content, lines, op, match) { + if (typeof op.newText !== 'string') return { ok: false, reason: 'missing_newText' }; + const charErr = validateNewTextChars(op.newText); + if (charErr) return { ok: false, reason: 'invalid_chars_in_newText', forbidden: charErr }; + const { startLine, endLine } = match; + const sub = lines.slice(startLine, endLine + 1).join('\n'); + const idx = sub.indexOf(op.originalText); + if (idx === -1) return { ok: false, reason: 'text_not_in_source' }; + // Same text appearing twice in the matched block means we can't tell which + // leaf the user edited. Refusing is safer than guessing — they can rephrase + // one occurrence to make it distinct, then retry. + const occurrences = sub.split(op.originalText).length - 1; + if (occurrences > 1) return { ok: false, reason: 'text_ambiguous_in_block', occurrences }; + const newSub = sub.slice(0, idx) + op.newText + sub.slice(idx + op.originalText.length); + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + // Use index checks, not string truthiness, so a leading empty line (file + // starts with '\n') or a trailing empty line is preserved instead of + // silently dropped during reconstruction. + const joined = + (startLine > 0 ? before + '\n' : '') + + newSub + + (endLine + 1 < lines.length ? '\n' + after : ''); + return { ok: true, content: joined }; +} + +function applyDelete(content, lines, op, match) { + const { startLine, endLine } = match; + if (endLine < startLine) return { ok: false, reason: 'unsafe_delete' }; + const closeRe = new RegExp(''); + if (!closeRe.test(lines[endLine])) return { ok: false, reason: 'unsafe_delete' }; + // If start and end share a line (e.g. `

x

` on one line), drop that line + // entirely. Otherwise drop the inclusive range. + const newLines = lines.slice(0, startLine).concat(lines.slice(endLine + 1)); + return { ok: true, content: newLines.join('\n') }; +} + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +function fatal(msg) { + console.error(JSON.stringify({ ok: false, error: msg })); + process.exit(1); +} + +// Reject characters that would land in source as markup, template delimiters, +// or template-string punctuation. The manual-edit flow is plain-text only; to +// insert markup the user asks the AI. Server and CLI share this check. +const FORBIDDEN_NEWTEXT_CHARS = ['<', '>', '{', '}', '`']; +export function validateNewTextChars(newText) { + if (typeof newText !== 'string') return null; + const hits = FORBIDDEN_NEWTEXT_CHARS.filter((c) => newText.includes(c)); + return hits.length > 0 ? hits : null; +} + +const _running = process.argv[1]; +if (_running?.endsWith('live-edit.mjs') || _running?.endsWith('live-edit.mjs/')) { + editCli(); +} + +// Test exports +export { resolveOp, applyTextReplace, applyDelete }; diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index 555c3a36..8497e0bc 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -116,7 +116,7 @@ Output (JSON): if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); - const withTag = insertTag(withoutOld, config, port); + const withTag = insertTag(withoutOld, config, port, relFile); if (withTag === withoutOld) { return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; } @@ -256,18 +256,22 @@ function validateConfig(cfg) { function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } -function buildTagBlock(syntax, port) { +function buildTagBlock(syntax, port, filePath) { const open = commentOpen(syntax); const close = commentClose(syntax); + // Astro processes \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } -function insertTag(content, config, port) { - const block = buildTagBlock(config.commentSyntax, port); +function insertTag(content, config, port, filePath) { + const block = buildTagBlock(config.commentSyntax, port, filePath); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/skill/scripts/live-manual-edits-buffer.mjs b/skill/scripts/live-manual-edits-buffer.mjs new file mode 100644 index 00000000..0f70f771 --- /dev/null +++ b/skill/scripts/live-manual-edits-buffer.mjs @@ -0,0 +1,171 @@ +/** + * Shared helpers for the pending-manual-edits buffer on disk. + * + * Location: .impeccable/live/pending-manual-edits.json (project-local). + * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] } + * + * Each entry corresponds to one Save action from the browser. Ops merge by + * (pageUrl, ref): if the user re-edits the same element before committing, the + * existing entry's `newText` is replaced and `originalText` is kept (it holds + * the real source state). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getLiveDir } from './impeccable-paths.mjs'; + +const BUFFER_VERSION = 1; +const BUFFER_FILENAME = 'pending-manual-edits.json'; + +export function getBufferPath(cwd = process.cwd()) { + return path.join(getLiveDir(cwd), BUFFER_FILENAME); +} + +export function readBuffer(cwd = process.cwd()) { + const filePath = getBufferPath(cwd); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + return { version: BUFFER_VERSION, entries: [] }; + } + return { version: BUFFER_VERSION, entries: parsed.entries }; + } catch { + return { version: BUFFER_VERSION, entries: [] }; + } +} + +export function writeBuffer(cwd, buffer) { + const filePath = getBufferPath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2)); +} + +/** + * Merge a new entry into the buffer. For each op in the new entry, if there's + * already a buffered op for the same (pageUrl, ref), update that op's newText + * and keep its original originalText (the true source state). Otherwise add + * the op (creating an entry if needed). + * + * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref). + */ +export function stageEntry(cwd, newEntry) { + const buf = readBuffer(cwd); + const pageUrl = newEntry.pageUrl; + for (const newOp of newEntry.ops) { + let mergedIntoExisting = false; + for (const existing of buf.entries) { + if (existing.pageUrl !== pageUrl) continue; + const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref); + if (existingOpIdx >= 0) { + // Update newText only; keep originalText. + existing.ops[existingOpIdx] = { + ...existing.ops[existingOpIdx], + newText: newOp.newText, + deleted: newOp.deleted || false, + }; + existing.stagedAt = new Date().toISOString(); + mergedIntoExisting = true; + break; + } + } + if (mergedIntoExisting) continue; + // No existing op for this (pageUrl, ref). Find or create an entry to hold it. + let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id); + if (!entry) { + entry = { + id: newEntry.id, + pageUrl, + element: newEntry.element, + ops: [], + stagedAt: new Date().toISOString(), + }; + buf.entries.push(entry); + } + entry.ops.push(newOp); + entry.stagedAt = new Date().toISOString(); + } + writeBuffer(cwd, buf); + return buf; +} + +/** + * Remove entries matching a predicate. Returns count of removed *ops* (not + * entries) so callers report a unit consistent with truncateBuffer and the + * pill's per-page op count. Empty entries (no ops left) are also pruned. + */ +export function removeEntries(cwd, predicate) { + const buf = readBuffer(cwd); + let removedOps = 0; + const kept = []; + for (const entry of buf.entries) { + if (predicate(entry)) { + removedOps += entry.ops?.length || 0; + } else if (entry.ops && entry.ops.length > 0) { + kept.push(entry); + } + } + buf.entries = kept; + writeBuffer(cwd, buf); + return removedOps; +} + +/** + * Remove a single op by (pageUrl, ref). Used by live-accept.mjs when a variant + * accept supersedes a pending manual edit on the same element ref. Empty + * entries are pruned. + */ +export function removeOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const before = entry.ops.length; + entry.ops = entry.ops.filter((op) => op.ref !== ref); + removed += before - entry.ops.length; + } + buf.entries = buf.entries.filter((entry) => entry.ops.length > 0); + writeBuffer(cwd, buf); + return removed; +} + +/** + * Look up a pending op for a given (pageUrl, ref). Returns the op or null. + * Used by live-wrap.mjs for buffer-aware "original" content in variant blocks. + */ +export function findOp(cwd, { pageUrl, ref }) { + const buf = readBuffer(cwd); + for (const entry of buf.entries) { + if (entry.pageUrl !== pageUrl) continue; + const op = entry.ops.find((op) => op.ref === ref); + if (op) return op; + } + return null; +} + +/** + * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }. + */ +export function countByPage(cwd = process.cwd()) { + const buf = readBuffer(cwd); + const perPage = {}; + let totalCount = 0; + for (const entry of buf.entries) { + const n = entry.ops.length; + perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n; + totalCount += n; + } + return { totalCount, perPage }; +} + +/** + * Truncate the buffer to empty (used by discard-all). Returns the count of + * removed ops. + */ +export function truncateBuffer(cwd) { + const buf = readBuffer(cwd); + let removed = 0; + for (const entry of buf.entries) removed += entry.ops.length; + writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] }); + return removed; +} diff --git a/skill/scripts/live-poll.mjs b/skill/scripts/live-poll.mjs index 10d45249..0c397b39 100644 --- a/skill/scripts/live-poll.mjs +++ b/skill/scripts/live-poll.mjs @@ -136,6 +136,9 @@ Options: break; } + // manual_edits flow through the /manual-edit endpoint server-side; the agent + // never sees them. No handler needed here. + // Auto-handle accept/discard via deterministic script if (event.type === 'accept' || event.type === 'discard') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 5205e553..88d3170f 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -31,6 +31,14 @@ import { resolveDesignSidecarPath, writeLiveServerInfo, } from './impeccable-paths.mjs'; +import { + countByPage as countPendingByPage, + readBuffer as readManualEditsBuffer, + removeEntries as removeManualEditEntries, + stageEntry as stageManualEditEntry, + truncateBuffer as truncateManualEditsBuffer, +} from './live-manual-edits-buffer.mjs'; +import { validateNewTextChars } from './live-edit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated @@ -171,15 +179,16 @@ function loadBrowserScripts() { // can re-read on every request. Editing the browser script during iteration // should land on the next tab reload, not require a server restart. const sessionPath = path.join(__dirname, 'live-browser-session.js'); + const textRowsPath = path.join(__dirname, 'live-text-rows.js'); const livePath = path.join(__dirname, 'live-browser.js'); - for (const p of [sessionPath, livePath]) { + for (const p of [sessionPath, textRowsPath, livePath]) { if (!fs.existsSync(p)) { process.stderr.write('Error: live browser script not found at ' + p + '\n'); process.exit(1); } } - return { detectScript, sessionPath, livePath }; + return { detectScript, sessionPath, textRowsPath, livePath }; } function hasProjectContext() { @@ -252,6 +261,26 @@ function validateEvent(msg) { case 'prefetch': if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; return null; + case 'manual_edits': + if (!isValidId(msg.id)) return 'manual_edits: missing or malformed id'; + if (!msg.element || typeof msg.element !== 'object') return 'manual_edits: missing element'; + if (!Array.isArray(msg.ops) || msg.ops.length === 0) return 'manual_edits: ops must be non-empty array'; + if (msg.ops.length > 100) return 'manual_edits: too many ops (max 100)'; + for (const op of msg.ops) { + if (typeof op.ref !== 'string') return 'manual_edits: op.ref required'; + if (typeof op.tag !== 'string') return 'manual_edits: op.tag required'; + if (typeof op.originalText !== 'string') return 'manual_edits: op.originalText required'; + if (op.deleted !== true && typeof op.newText !== 'string') { + return 'manual_edits: text op requires newText'; + } + if (typeof op.newText === 'string') { + const forbidden = validateNewTextChars(op.newText); + if (forbidden) { + return 'manual_edits: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)'; + } + } + } + return null; default: return 'Unknown event type: ' + msg.type; } @@ -261,7 +290,7 @@ function validateEvent(msg) { // HTTP request handler // --------------------------------------------------------------------------- -function createRequestHandler({ detectScript, sessionPath, livePath }) { +function createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -278,9 +307,11 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { // sessions — during iteration, a cached old script silently breaks // every subsequent session. let sessionScript; + let textRowsScript; let liveScript; try { sessionScript = fs.readFileSync(sessionPath, 'utf-8'); + textRowsScript = fs.readFileSync(textRowsPath, 'utf-8'); liveScript = fs.readFileSync(livePath, 'utf-8'); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -291,6 +322,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + sessionScript + '\n' + + textRowsScript + '\n' + liveScript; res.writeHead(200, { 'Content-Type': 'application/javascript', @@ -527,6 +559,126 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } + // --- Manual edits: stash to disk buffer, no source write, no HMR. + // Browser POSTs here on Save. The agent never sees these events. To commit + // the buffer to source, the user explicitly asks the AI to run + // live-commit-manual-edits.mjs. See reference/live.md for the full contract. + if (p === '/manual-edit-stash' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent({ ...msg, type: 'manual_edits' }); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + try { + stageManualEditEntry(process.cwd(), { + id: msg.id, + pageUrl: msg.pageUrl, + element: msg.element, + ops: msg.ops, + }); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const pendingCount = perPage[msg.pageUrl] || 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); + }); + return; + } + + // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries } + if (p === '/manual-edit-stash' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl') || ''; + const { totalCount, perPage } = countPendingByPage(process.cwd()); + const buffer = readManualEditsBuffer(process.cwd()); + const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, + totalCount, + perPage, + entries: entriesForPage, + })); + return; + } + + // POST /manual-edit-commit?pageUrl= → shells out to live-commit-manual-edits.mjs + // Same effect as the AI running the script, but triggered from the overlay pill. + if (p === '/manual-edit-commit' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + const commitScript = path.join(__dirname, 'live-commit-manual-edits.mjs'); + const scriptArgs = pageUrl ? ['--page-url=' + pageUrl] : []; + let result; + try { + const out = execFileSync( + 'node', + [commitScript, ...scriptArgs], + { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 } + ); + result = JSON.parse(out.trim()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'commit_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ...result, totalCount, perPage })); + return; + } + + // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl) + if (p === '/manual-edit-discard' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const pageUrl = url.searchParams.get('pageUrl'); + let discarded; + try { + if (pageUrl) { + discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); + } else { + discarded = truncateManualEditsBuffer(process.cwd()); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); + return; + } + const { totalCount, perPage } = countPendingByPage(process.cwd()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ discarded, totalCount, perPage })); + return; + } + + // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. + if (p === '/manual-edit' && req.method === 'POST') { + res.writeHead(410, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '/manual-edit is removed; POST to /manual-edit-stash. Then ask the AI to commit.' })); + return; + } + // --- Browser→server events (replaces WebSocket messages) --- if (p === '/events' && req.method === 'POST') { let body = ''; @@ -543,6 +695,14 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { res.end(JSON.stringify({ error: 'Unauthorized' })); return; } + // Defense in depth: manual_edits must use /manual-edit, not /events. + // Reject loudly so stale browser code surfaces in dev rather than + // silently re-creating the agent loop. + if (msg.type === 'manual_edits') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit, not /events' })); + return; + } const error = validateEvent(msg); if (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -820,8 +980,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd()); fs.mkdirSync(annotRoot, { recursive: true }); state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); -const { detectScript, sessionPath, livePath } = loadBrowserScripts(); -httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); +const { detectScript, sessionPath, textRowsPath, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, textRowsPath, livePath })); httpServer.listen(state.port, '127.0.0.1', () => { writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); diff --git a/skill/scripts/live-text-rows.js b/skill/scripts/live-text-rows.js new file mode 100644 index 00000000..2cab4694 --- /dev/null +++ b/skill/scripts/live-text-rows.js @@ -0,0 +1,79 @@ +/** + * Browser-side walker that surfaces editable text rows for the manual text-edit + * popover under the Live-Bar. Served before live-browser.js and attached to + * window.__IMPECCABLE_LIVE_TEXT_ROWS__. + * + * Rule: emit a row for an element iff every direct child is a Text node AND at + * least one of those text nodes has non-whitespace content. Mixed-content + * elements (text interleaved with element children) do not emit their own row; + * pure-text leaves deeper in the subtree still emit. + */ +(function (root) { + 'use strict'; + + var SKIP_SUBTREE_TAGS = { + script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1, + }; + + function collectEditableTextRows(rootEl, opts) { + if (!rootEl || rootEl.nodeType !== 1) return []; + var isOwn = (opts && opts.isOwn) || function () { return false; }; + var rows = []; + + function visit(el) { + if (!el || el.nodeType !== 1) return; + var tag = el.tagName.toLowerCase(); + if (SKIP_SUBTREE_TAGS[tag]) return; + if (el.hasAttribute && el.hasAttribute('contenteditable')) return; + if (el !== rootEl && isOwn(el)) return; + + var children = el.childNodes; + var hasChild = children.length > 0; + var allText = hasChild; + var hasNonWs = false; + var textNodes = []; + for (var i = 0; i < children.length; i++) { + var node = children[i]; + if (node.nodeType === 3) { + textNodes.push(node); + if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWs = true; + } else { + allText = false; + } + } + if (allText && hasNonWs) { + rows.push({ + el: el, + ref: (el === rootEl) ? tag : refForDescendant(el), + text: textNodes.map(function (n) { return n.nodeValue; }).join(''), + textNodes: textNodes, + }); + } + + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (c.nodeType === 1) visit(c); + } + } + + function refForDescendant(el) { + var parent = el.parentElement; + var tag = el.tagName.toLowerCase(); + if (!parent) return tag; + var n = 0; + var sibs = parent.children; + for (var i = 0; i < sibs.length; i++) { + if (sibs[i].tagName.toLowerCase() === tag) { + n++; + if (sibs[i] === el) break; + } + } + return parent.tagName.toLowerCase() + '>' + tag + '.' + n; + } + + visit(rootEl); + return rows; + } + + root.__IMPECCABLE_LIVE_TEXT_ROWS__ = { collectEditableTextRows: collectEditableTextRows }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/skill/scripts/live-wrap.mjs b/skill/scripts/live-wrap.mjs index d46328b9..5e7246fc 100644 --- a/skill/scripts/live-wrap.mjs +++ b/skill/scripts/live-wrap.mjs @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './is-generated.mjs'; +import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; @@ -41,6 +42,10 @@ Optional: classes/tag match multiple sibling elements (e.g. a list of s with the same className). Pass the first ~80 chars of event.element.textContent. + --page-url URL Current page URL. Required for the buffer-aware "original" + content step: pending manual edits are filtered to this + page so an edit on /a doesn't bleed into a wrap on /b. If + omitted, the buffer-aware step is skipped. --help Show this help message Output (JSON): @@ -58,6 +63,7 @@ The agent should insert variant HTML at insertLine.`); const query = argVal(args, '--query'); const filePath = argVal(args, '--file'); const text = argVal(args, '--text'); + const pageUrl = argVal(args, '--page-url'); if (!id) { console.error('Missing --id'); process.exit(1); } if (!elementId && !classes && !query) { @@ -196,7 +202,49 @@ The agent should insert variant HTML at insertLine.`); // the inner element at its parent's depth instead of nested inside it. // Strip only the COMMON minimum leading whitespace across the picked lines; // `deindentContent` on the accept side already mirrors this convention. - const originalLines = lines.slice(startLine, endLine + 1); + let originalLines = lines.slice(startLine, endLine + 1); + + // Buffer-aware "original" content: if the user has pending manual edits for + // this page whose originalText appears in the picked source range, apply + // them so the wrap block's "original" variant reflects what the user was + // looking at (their edited DOM), not the raw source. Source itself stays + // untouched here — only the wrap block's embedded "original" copy is + // adjusted. The pending edits remain in the buffer until committed. + // + // Refuse-with-error rather than silently skipping when the buffer has + // entries and --page-url is missing. The silent-skip case let agents that + // forgot the flag author variants off un-edited source while the user was + // seeing edited DOM, producing a "why isn't my edit reflected?" mystery. + // Empty buffer = no risk = no requirement. + let pendingBuffer = { entries: [] }; + try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {} + if (pendingBuffer.entries.length > 0 && !pageUrl) { + console.error(JSON.stringify({ + error: 'missing_page_url_with_pending_edits', + pendingEntries: pendingBuffer.entries.length, + hint: 'The manual-edit buffer has pending entries. Pass --page-url=$event.pageUrl so the wrap block\'s "original" content reflects the user\'s staged DOM, not the un-edited source. See reference/live.md, "Wrap the element".', + })); + process.exit(1); + } + if (pageUrl) { + try { + let originalBlock = originalLines.join('\n'); + let mutated = false; + for (const entry of pendingBuffer.entries) { + if (entry.pageUrl !== pageUrl) continue; + for (const op of entry.ops) { + if (op.originalText && op.newText !== undefined && originalBlock.includes(op.originalText)) { + originalBlock = originalBlock.replace(op.originalText, op.newText); + mutated = true; + } + } + } + if (mutated) originalLines = originalBlock.split('\n'); + } catch { + // Buffer read failures are non-fatal; fall back to source-as-is. + } + } + const originalBaseIndent = minLeadingSpaces(originalLines); const reindentOriginal = (extra) => originalLines .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) @@ -628,5 +676,14 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) wrapCli(); } -// Test exports (used by tests/live-wrap.test.mjs) -export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax }; +// Test exports (used by tests/live-wrap.test.mjs) and reusable primitives +// for sibling scripts (live-edit.mjs reuses the locator + file resolver). +export { + buildSearchQueries, + findElement, + findAllElements, + filterByText, + findClosingLine, + findFileWithQuery, + detectCommentSyntax, +}; diff --git a/tests/live-accept-scrub.test.mjs b/tests/live-accept-scrub.test.mjs new file mode 100644 index 00000000..0b753090 --- /dev/null +++ b/tests/live-accept-scrub.test.mjs @@ -0,0 +1,91 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { writeBuffer, readBuffer } from '../skill/scripts/live-manual-edits-buffer.mjs'; +import { scrubManualEditsAgainstFile } from '../skill/scripts/live-accept.mjs'; + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scrub-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function entry({ id = 'e1', pageUrl = '/', ops = [] } = {}) { + return { id, pageUrl, element: { tagName: 'p' }, ops, stagedAt: new Date().toISOString() }; +} + +function op({ ref = 'div>p.1', originalText = 'A', newText = 'B' } = {}) { + return { ref, tag: 'p', classes: ['x'], originalText, newText }; +} + +describe('scrubManualEditsAgainstFile', () => { + it('drops ops whose originalText is no longer in the file', () => { + const file = path.join(tmpDir, 'page.html'); + fs.writeFileSync(file, '

Kept text

\n'); + + writeBuffer(tmpDir, { + entries: [ + entry({ + ops: [ + op({ ref: 'r1', originalText: 'Kept text' }), + op({ ref: 'r2', originalText: 'Stale text not in file' }), + ], + }), + ], + }); + + scrubManualEditsAgainstFile(file, tmpDir); + + const buf = readBuffer(tmpDir); + assert.equal(buf.entries.length, 1); + assert.equal(buf.entries[0].ops.length, 1); + assert.equal(buf.entries[0].ops[0].ref, 'r1'); + }); + + it('keeps ops whose originalText still appears in the file', () => { + const file = path.join(tmpDir, 'page.html'); + fs.writeFileSync(file, '

Welcome

Body copy

\n'); + + writeBuffer(tmpDir, { + entries: [ + entry({ ops: [op({ ref: 'r1', originalText: 'Welcome' }), op({ ref: 'r2', originalText: 'Body copy' })] }), + ], + }); + + scrubManualEditsAgainstFile(file, tmpDir); + + const buf = readBuffer(tmpDir); + assert.equal(buf.entries[0].ops.length, 2); + }); + + it('prunes entries whose ops are all stale', () => { + const file = path.join(tmpDir, 'page.html'); + fs.writeFileSync(file, '

Something else entirely

\n'); + + writeBuffer(tmpDir, { + entries: [ + entry({ id: 'doomed', ops: [op({ originalText: 'Gone A' }), op({ originalText: 'Gone B' })] }), + entry({ id: 'survivor', ops: [op({ originalText: 'Something else entirely' })] }), + ], + }); + + scrubManualEditsAgainstFile(file, tmpDir); + + const buf = readBuffer(tmpDir); + assert.equal(buf.entries.length, 1); + assert.equal(buf.entries[0].id, 'survivor'); + }); + + it('is a no-op when the buffer is empty', () => { + const file = path.join(tmpDir, 'page.html'); + fs.writeFileSync(file, '
\n'); + scrubManualEditsAgainstFile(file, tmpDir); + assert.equal(readBuffer(tmpDir).entries.length, 0); + }); +}); diff --git a/tests/live-commit-manual-edits.test.mjs b/tests/live-commit-manual-edits.test.mjs new file mode 100644 index 00000000..60d2cef5 --- /dev/null +++ b/tests/live-commit-manual-edits.test.mjs @@ -0,0 +1,124 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { writeBuffer, readBuffer } from '../skill/scripts/live-manual-edits-buffer.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(REPO_ROOT, 'skill/scripts/live-commit-manual-edits.mjs'); + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'commit-test-')); + fs.mkdirSync(path.join(tmpDir, 'src')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function entry({ id, pageUrl, ops }) { + return { + id, + pageUrl, + element: { tagName: 'h1' }, + ops, + stagedAt: new Date().toISOString(), + }; +} + +function runCommit(extraArgs = []) { + const args = [SCRIPT, ...extraArgs]; + const stdout = execFileSync('node', args, { encoding: 'utf-8', cwd: tmpDir }); + return JSON.parse(stdout.trim()); +} + +describe('live-commit-manual-edits.mjs', () => { + it('applies a single op and clears the entry from the buffer', () => { + const file = path.join(tmpDir, 'src', 'page.html'); + fs.writeFileSync(file, '
\n

Welcome

\n
\n'); + writeBuffer(tmpDir, { + entries: [ + entry({ + id: 'e1', + pageUrl: '/', + ops: [{ ref: 'div>h1.1', tag: 'h1', classes: ['hero'], originalText: 'Welcome', newText: 'Hello' }], + }), + ], + }); + + const result = runCommit(); + + assert.equal(result.cleared, true); + assert.equal(result.applied.length, 1); + assert.equal(result.failed.length, 0); + assert.match(fs.readFileSync(file, 'utf-8'), /Hello/); + assert.equal(readBuffer(tmpDir).entries.length, 0); + }); + + it('preserves only the failed op when an entry has mixed outcomes', () => { + const file = path.join(tmpDir, 'src', 'page.html'); + fs.writeFileSync(file, '
\n

Welcome

\n

Body copy

\n
\n'); + writeBuffer(tmpDir, { + entries: [ + entry({ + id: 'mixed', + pageUrl: '/', + ops: [ + // good op + { ref: 'div>h1.1', tag: 'h1', classes: ['hero'], originalText: 'Welcome', newText: 'Hello' }, + // bad op — originalText not in source + { ref: 'div>p.1', tag: 'p', classes: ['lede'], originalText: 'Nope', newText: 'X' }, + ], + }), + ], + }); + + const result = runCommit(); + + assert.equal(result.cleared, false); + assert.equal(result.applied.length, 1); + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].reason, 'text_not_in_source'); + + const buf = readBuffer(tmpDir); + assert.equal(buf.entries.length, 1, 'failed op stays in buffer'); + assert.equal(buf.entries[0].ops.length, 1); + assert.equal(buf.entries[0].ops[0].ref, 'div>p.1'); + }); + + it('--page-url scopes commit; entries for other pages survive untouched', () => { + const a = path.join(tmpDir, 'src', 'a.html'); + const b = path.join(tmpDir, 'src', 'b.html'); + fs.writeFileSync(a, '
\n

A original

\n
\n'); + fs.writeFileSync(b, '
\n

B original

\n
\n'); + + writeBuffer(tmpDir, { + entries: [ + entry({ id: 'a', pageUrl: '/a', ops: [{ ref: 'div>h1.1', tag: 'h1', classes: ['aa'], originalText: 'A original', newText: 'A new' }] }), + entry({ id: 'b', pageUrl: '/b', ops: [{ ref: 'div>h1.1', tag: 'h1', classes: ['bb'], originalText: 'B original', newText: 'B new' }] }), + ], + }); + + const result = runCommit(['--page-url=/a']); + + assert.equal(result.cleared, true); + assert.match(fs.readFileSync(a, 'utf-8'), /A new/); + assert.match(fs.readFileSync(b, 'utf-8'), /B original/, 'page /b is untouched'); + + const buf = readBuffer(tmpDir); + assert.equal(buf.entries.length, 1); + assert.equal(buf.entries[0].pageUrl, '/b'); + }); + + it('reports no_pending_edits when buffer is empty', () => { + const result = runCommit(); + assert.equal(result.reason, 'no_pending_edits'); + assert.equal(result.applied.length, 0); + }); +}); diff --git a/tests/live-discard-manual-edits.test.mjs b/tests/live-discard-manual-edits.test.mjs new file mode 100644 index 00000000..28e4084f --- /dev/null +++ b/tests/live-discard-manual-edits.test.mjs @@ -0,0 +1,76 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { writeBuffer, readBuffer } from '../skill/scripts/live-manual-edits-buffer.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(REPO_ROOT, 'skill/scripts/live-discard-manual-edits.mjs'); + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discard-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function entry({ id, pageUrl, ops }) { + return { id, pageUrl, element: { tagName: 'p' }, ops, stagedAt: new Date().toISOString() }; +} + +function op({ ref = 'div>p.1', newText = 'B' } = {}) { + return { ref, tag: 'p', classes: ['x'], originalText: 'A', newText }; +} + +function runDiscard(extraArgs = []) { + const args = [SCRIPT, ...extraArgs]; + const stdout = execFileSync('node', args, { encoding: 'utf-8', cwd: tmpDir }); + return JSON.parse(stdout.trim()); +} + +describe('live-discard-manual-edits.mjs', () => { + it('no filter: returns total op count and empties the buffer', () => { + writeBuffer(tmpDir, { + entries: [ + entry({ id: 'a', pageUrl: '/a', ops: [op({ ref: 'r1' }), op({ ref: 'r2' })] }), + entry({ id: 'b', pageUrl: '/b', ops: [op({ ref: 'r3' })] }), + ], + }); + + const result = runDiscard(); + + assert.equal(result.discarded, 3, 'reports ops removed, not entries'); + assert.equal(result.totalCount, 0); + assert.equal(readBuffer(tmpDir).entries.length, 0); + }); + + it('--page-url scopes the discard; other pages survive', () => { + writeBuffer(tmpDir, { + entries: [ + entry({ id: 'a', pageUrl: '/a', ops: [op({ ref: 'r1' }), op({ ref: 'r2' })] }), + entry({ id: 'b', pageUrl: '/b', ops: [op({ ref: 'r3' })] }), + ], + }); + + const result = runDiscard(['--page-url=/a']); + + assert.equal(result.discarded, 2, 'reports ops on /a, not entries (CB-5)'); + assert.equal(result.totalCount, 1); + const buf = readBuffer(tmpDir); + assert.equal(buf.entries.length, 1); + assert.equal(buf.entries[0].pageUrl, '/b'); + }); + + it('returns zero when buffer is already empty', () => { + const result = runDiscard(); + assert.equal(result.discarded, 0); + assert.equal(result.totalCount, 0); + }); +}); diff --git a/tests/live-edit.test.mjs b/tests/live-edit.test.mjs new file mode 100644 index 00000000..5b41cf0c --- /dev/null +++ b/tests/live-edit.test.mjs @@ -0,0 +1,200 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(REPO_ROOT, 'skill/scripts/live-edit.mjs'); + +function runEdit(cwd, ops, extraArgs = []) { + const args = [SCRIPT, '--id', 'aaaaaaaa', '--ops', JSON.stringify(ops), ...extraArgs]; + let stdout; + try { + stdout = execFileSync('node', args, { encoding: 'utf-8', cwd }); + } catch (err) { + return { ok: false, error: err.message, stderr: err.stderr }; + } + return JSON.parse(stdout.trim()); +} + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'live-edit-test-')); + // Mirror the dir layout findFileWithQuery searches by default + fs.mkdirSync(path.join(tmpDir, 'src')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('live-edit.mjs', () => { + it('single text-replace rewrites source', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '
\n

Build faster

\n

Without the cognitive load

\n
\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>h2.1', tag: 'h2', classes: ['title'], originalText: 'Build faster', newText: 'Ship faster' }, + ]); + + assert.equal(result.ok, true); + assert.equal(result.applied.length, 1); + assert.equal(result.failed.length, 0); + const after = fs.readFileSync(file, 'utf-8'); + assert.match(after, /

Ship faster<\/h2>/); + assert.doesNotMatch(after, /Build faster/); + }); + + it('two text-replaces in same file applied in one write', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '
\n

Build faster

\n

Without the cognitive load

\n
\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>h2.1', tag: 'h2', classes: ['title'], originalText: 'Build faster', newText: 'Ship faster' }, + { ref: 'div>p.1', tag: 'p', classes: ['lede'], originalText: 'Without the cognitive load', newText: 'Without the noise' }, + ]); + + assert.equal(result.ok, true); + assert.equal(result.applied.length, 2); + const after = fs.readFileSync(file, 'utf-8'); + assert.match(after, /Ship faster/); + assert.match(after, /Without the noise/); + }); + + it('block-delete removes the matched element', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '
\n

Build faster

\n

remove me

\n

keep me

\n
\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>p.1', tag: 'p', classes: ['kill'], originalText: 'remove me', deleted: true }, + ]); + + assert.equal(result.ok, true); + assert.equal(result.applied.length, 1); + const after = fs.readFileSync(file, 'utf-8'); + assert.doesNotMatch(after, /remove me/); + assert.match(after, /keep me/); + }); + + it('reports text_not_in_source when originalText absent from source', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '
\n

{title}

\n
\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>h2.1', tag: 'h2', classes: ['title'], originalText: 'Build faster', newText: 'Ship faster' }, + ]); + + assert.equal(result.ok, true); + assert.equal(result.applied.length, 0); + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].reason, 'text_not_in_source'); + }); + + it('reports insufficient_locator when neither id nor classes', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '

Hello

\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>p.1', tag: 'p', originalText: 'Hello', newText: 'World' }, + ]); + + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].reason, 'insufficient_locator'); + }); + + it('locates by id when classes are absent', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '
\n

Hello

\n
\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>p.1', tag: 'p', elementId: 'lede', originalText: 'Hello', newText: 'World' }, + ]); + + assert.equal(result.ok, true); + assert.equal(result.applied.length, 1); + assert.match(fs.readFileSync(file, 'utf-8'), /

World<\/p>/); + }); + + it('disambiguates two matching elements via originalText (filterByText)', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync( + file, + '

\n' + + '

Build faster than your competition

\n' + + '

Ship faster without the cognitive load

\n' + + '
\n' + ); + + const result = runEdit(tmpDir, [ + { + ref: 'div>section.2', + tag: 'section', + classes: ['card'], + originalText: 'Ship faster without the cognitive load', + newText: 'Ship without thinking too hard about it', + }, + ]); + + assert.equal(result.ok, true); + assert.equal(result.applied.length, 1); + const after = fs.readFileSync(file, 'utf-8'); + assert.match(after, /Ship without thinking too hard about it/); + assert.match(after, /Build faster than your competition/); + }); + + it('refuses with text_ambiguous_in_block when originalText appears more than once (A3)', () => { + const file = path.join(tmpDir, 'src', 'list.html'); + fs.writeFileSync(file, + '
    \n' + + '
  • Item
  • \n' + + '
  • Item
  • \n' + + '
\n' + ); + + const result = runEdit(tmpDir, [ + { ref: 'ul>li.2', tag: 'ul', classes: ['items'], originalText: 'Item', newText: 'Renamed' }, + ]); + + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].reason, 'text_ambiguous_in_block'); + assert.equal(result.applied.length, 0); + // Source is untouched — refuse-on-ambiguity. + const after = fs.readFileSync(file, 'utf-8'); + assert.doesNotMatch(after, /Renamed/); + }); + + it('rejects newText containing forbidden chars (A4)', () => { + const file = path.join(tmpDir, 'src', 'card.html'); + fs.writeFileSync(file, '
\n

Hello

\n
\n'); + + for (const bad of ['<', '>', '{', '}', '`']) { + const result = runEdit(tmpDir, [ + { ref: 'div>p.1', tag: 'p', classes: ['lede'], originalText: 'Hello', newText: 'safe ' + bad + ' rest' }, + ]); + assert.equal(result.failed.length, 1, 'failed for ' + bad); + assert.equal(result.failed[0].reason, 'invalid_chars_in_newText'); + assert.ok(result.failed[0].forbidden.includes(bad)); + } + }); + + it('handles ops resolving to different files independently', () => { + const a = path.join(tmpDir, 'src', 'a.html'); + const b = path.join(tmpDir, 'src', 'b.html'); + fs.writeFileSync(a, '
\n

Welcome

\n
\n'); + fs.writeFileSync(b, '
\n

Click me

\n
\n'); + + const result = runEdit(tmpDir, [ + { ref: 'div>h1.1', tag: 'h1', classes: ['hero'], originalText: 'Welcome', newText: 'Hello' }, + { ref: 'div>h1.1', tag: 'h1', classes: ['cta'], originalText: 'Click me', newText: 'Tap me' }, + ]); + + assert.equal(result.applied.length, 2); + assert.match(fs.readFileSync(a, 'utf-8'), /Hello/); + assert.match(fs.readFileSync(b, 'utf-8'), /Tap me/); + }); +}); diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs index c3e32c8a..e1b4cf01 100644 --- a/tests/live-inject.test.mjs +++ b/tests/live-inject.test.mjs @@ -241,6 +241,53 @@ describe('live-inject — insert/remove round-trip preserves file bytes', () => assert.equal(after, original, 'CSP meta tag must round-trip exactly through insert+remove'); }); + it('emits is:inline on script tag for .astro files (Astro otherwise rewrites src) and round-trips', () => { + const original = `--- +const title = 'Test'; +--- + + +

{title}

+ + +`; + const file = join(tmp, 'Layout.astro'); + writeFileSync(file, original); + + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + files: ['Layout.astro'], + insertBefore: '', + commentSyntax: 'html', + })); + + runInject(tmp, cfgPath, ['--port', '8400']); + const afterInject = readFileSync(file, 'utf-8'); + assert.match(afterInject, /z'); + const rows = collect(root); + assert.equal(rows.length, 0); + }); + + it('contenteditable element is skipped entirely', () => { + const { dom, collect } = loadCollector(); + const root = setBody(dom, '

edit me

plain

'); + const rows = collect(root); + assert.equal(rows.length, 1); + assert.equal(rows[0].ref, 'div>p.2'); + assert.equal(rows[0].text, 'plain'); + }); + + it('deeply nested pure-text descendant emits with parent>tag.N', () => { + const { dom, collect } = loadCollector(); + const root = setBody(dom, '

copy

'); + const rows = collect(root); + assert.equal(rows.length, 1); + assert.equal(rows[0].ref, 'article>p.1'); + assert.equal(rows[0].text, 'copy'); + }); + + it('isOwn hook skips Impeccable chrome inside the subtree', () => { + const { dom, collect } = loadCollector(); + const root = setBody( + dom, + '

real

chrome

' + ); + const isOwn = (el) => !!(el.id && el.id.indexOf('impeccable-live') === 0); + const rows = collect(root, { isOwn }); + assert.deepEqual(rows.map((r) => r.text), ['real']); + }); + + it('returns empty array for non-element input', () => { + const { collect } = loadCollector(); + assert.deepEqual(collect(null), []); + assert.deepEqual(collect(undefined), []); + }); + + it('multiple direct text nodes are concatenated', () => { + const { dom, collect } = loadCollector(); + const doc = dom.window.document; + const p = doc.createElement('p'); + p.appendChild(doc.createTextNode('foo ')); + p.appendChild(doc.createTextNode('bar')); + doc.body.innerHTML = ''; + doc.body.appendChild(p); + const rows = collect(p); + assert.equal(rows.length, 1); + assert.equal(rows[0].text, 'foo bar'); + assert.equal(rows[0].textNodes.length, 2); + }); +}); diff --git a/tests/live-wrap-buffer-aware.test.mjs b/tests/live-wrap-buffer-aware.test.mjs new file mode 100644 index 00000000..efde8147 --- /dev/null +++ b/tests/live-wrap-buffer-aware.test.mjs @@ -0,0 +1,113 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { writeBuffer } from '../skill/scripts/live-manual-edits-buffer.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(REPO_ROOT, 'skill/scripts/live-wrap.mjs'); + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wrap-buf-test-')); + fs.mkdirSync(path.join(tmpDir, 'src')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function seedBuffer(entries) { + writeBuffer(tmpDir, { entries }); +} + +function entry({ pageUrl, ops }) { + return { + id: 'e' + Math.random().toString(36).slice(2, 8), + pageUrl, + element: { tagName: 'h1' }, + ops, + stagedAt: new Date().toISOString(), + }; +} + +function runWrap(extraArgs) { + const args = [SCRIPT, '--id', 'aaaaaaaa', '--count', '3', ...extraArgs]; + const stdout = execFileSync('node', args, { encoding: 'utf-8', cwd: tmpDir }); + return JSON.parse(stdout.trim()); +} + +function runWrapExpectFailure(extraArgs) { + const args = [SCRIPT, '--id', 'aaaaaaaa', '--count', '3', ...extraArgs]; + try { + execFileSync('node', args, { encoding: 'utf-8', cwd: tmpDir, stdio: ['ignore', 'pipe', 'pipe'] }); + throw new Error('expected wrap to fail'); + } catch (err) { + if (err.status === undefined) throw err; + return { status: err.status, stderr: err.stderr.toString() }; + } +} + +describe('live-wrap.mjs buffer-aware "original" content', () => { + it('with matching --page-url, rewrites the wrap block to reflect the buffered edit', () => { + const file = path.join(tmpDir, 'src', 'page.html'); + fs.writeFileSync(file, '
\n

Welcome

\n
\n'); + + seedBuffer([ + entry({ pageUrl: '/', ops: [{ ref: 'div>h1.1', tag: 'h1', classes: ['hero'], originalText: 'Welcome', newText: 'Hello there' }] }), + ]); + + runWrap(['--classes', 'hero', '--tag', 'h1', '--page-url', '/']); + + const after = fs.readFileSync(file, 'utf-8'); + assert.match(after, /Hello there/); + assert.doesNotMatch(after, /

Welcome<\/h1>/); + }); + + it('with mismatched --page-url, does NOT leak the edit (CB-4 regression)', () => { + const file = path.join(tmpDir, 'src', 'page.html'); + fs.writeFileSync(file, '
\n

Welcome

\n
\n'); + + // Buffer has an edit for "/a" — wrap is called for "/b" + seedBuffer([ + entry({ pageUrl: '/a', ops: [{ ref: 'div>h1.1', tag: 'h1', classes: ['hero'], originalText: 'Welcome', newText: 'LEAK' }] }), + ]); + + runWrap(['--classes', 'hero', '--tag', 'h1', '--page-url', '/b']); + + const after = fs.readFileSync(file, 'utf-8'); + assert.match(after, /Welcome/); + assert.doesNotMatch(after, /LEAK/); + }); + + it('with pending entries in the buffer, refuses without --page-url (fail-loud)', () => { + const file = path.join(tmpDir, 'src', 'page.html'); + fs.writeFileSync(file, '
\n

Welcome

\n
\n'); + + seedBuffer([ + entry({ pageUrl: '/', ops: [{ ref: 'div>h1.1', tag: 'h1', classes: ['hero'], originalText: 'Welcome', newText: 'SHOULD_NOT_APPEAR' }] }), + ]); + + const result = runWrapExpectFailure(['--classes', 'hero', '--tag', 'h1']); + assert.equal(result.status, 1); + const errPayload = JSON.parse(result.stderr.split('\n').filter((l) => l.trim().startsWith('{')).pop()); + assert.equal(errPayload.error, 'missing_page_url_with_pending_edits'); + assert.equal(errPayload.pendingEntries, 1); + // Source untouched — wrap exited before writing. + assert.equal(fs.readFileSync(file, 'utf-8'), '
\n

Welcome

\n
\n'); + }); + + it('with empty buffer, --page-url is optional', () => { + const file = path.join(tmpDir, 'src', 'page.html'); + fs.writeFileSync(file, '
\n

Welcome

\n
\n'); + // No seedBuffer call — empty buffer. + + const result = runWrap(['--classes', 'hero', '--tag', 'h1']); + assert.ok(result.file, 'wrap should succeed and emit file path'); + }); +}); diff --git a/tests/live-wrap.test.mjs b/tests/live-wrap.test.mjs index 0b010026..b0647bd8 100644 --- a/tests/live-wrap.test.mjs +++ b/tests/live-wrap.test.mjs @@ -204,10 +204,12 @@ describe('wrapCli integration', () => { beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-wrap-test-')); + clearManualEditsBuffer(); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); + clearManualEditsBuffer(); }); it('wraps an HTML element by class name', () => { @@ -384,10 +386,20 @@ const title = 'Astro title'; // Regression tests from real-world failures (EAC report, 2026-04) // --------------------------------------------------------------------------- +// Integration tests share cwd=process.cwd() with the repo, so a leftover +// .impeccable/live/pending-manual-edits.json from local dev tripped the +// fail-loud check in live-wrap. Clear the buffer around each test. +function clearManualEditsBuffer() { + try { + const p = join(process.cwd(), '.impeccable/live/pending-manual-edits.json'); + rmSync(p, { force: true }); + } catch {} +} + describe('live-wrap — JSX / TSX correctness', () => { let tmp; - beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-wrap-jsx-')); }); - afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-wrap-jsx-')); clearManualEditsBuffer(); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); clearManualEditsBuffer(); }); it('wraps the correct
when a class collides with a multi-line tag elsewhere', () => { // Decoy section: multi-line JSX with `organic-sand-surface` inside className