From e46503be6437e5e9961f9cf62507f23f0a07a9ff Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:21:34 +0200 Subject: [PATCH 1/9] fix(client-sdk): keep compose draft alive through scroll and reload The window scroll listener is capture-phase, so it also fired when the compose textarea scrolled internally once a note grew past its fixed height, closing the box and dropping the draft mid-type. Ignore loupe's own UI in that listener, reposition the compose box on real page scroll instead of closing it (via the existing rAF tick loop), and let the textarea auto-grow before it needs to scroll at all. Also persist the in-progress draft (text, selector, caret) to sessionStorage so it survives the shell reloading the artifact iframe when the underlying file changes, and restore it (including focus, but only if the textarea itself was genuinely focused) once the anchor element can be found again. --- dist/browser/client-sdk.js | 159 ++++++++++++++++++++++++++++++++++--- src/browser/client-sdk.ts | 153 +++++++++++++++++++++++++++++++++-- 2 files changed, 292 insertions(+), 20 deletions(-) diff --git a/dist/browser/client-sdk.js b/dist/browser/client-sdk.js index 0665b37..16ad3b9 100644 --- a/dist/browser/client-sdk.js +++ b/dist/browser/client-sdk.js @@ -7,6 +7,23 @@ let composeBox = null; let markerContainer = null; let markers = {}; let presenceWorking = false; +// Draft persistence: survives the artifact iframe being torn down and reloaded +// (the shell reloads the artifact whenever the underlying file changes on disk). +// sessionStorage is scoped to the tab + origin, not to a specific document instance, +// so it survives `artifactFrame.src = ...` reassignment as long as the iframe stays +// same-origin with the shell, which it is here (both served by the loupe server). +const DRAFT_KEY = 'loupe:compose-draft:' + key; +const TEXTAREA_MIN_HEIGHT = 56; +const TEXTAREA_MAX_HEIGHT = 200; +// Set while a compose box is open, to flush its latest state to sessionStorage +// on pagehide (fired just before the iframe document is torn down on reload). +let persistCurrentDraft = null; +function clearPersistedDraft() { + try { + sessionStorage.removeItem(DRAFT_KEY); + } + catch (_e) { } +} // Inject pulse keyframe once at module load. const _pulseStyle = document.createElement('style'); _pulseStyle.textContent = '@keyframes loupe-pulse{0%,100%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 0 rgba(245,158,11,0.55)}50%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 5px rgba(245,158,11,0)}} .loupe-awaiting-pulse{animation:loupe-pulse 1.4s ease-in-out infinite;}'; @@ -172,14 +189,14 @@ function showSelOv(el) { selOv.style.display = 'block'; } /* ---- inline compose ---- */ -function openCompose(el, selector) { - if (composeBox) { - composeBox.remove(); - composeBox = null; - } - const contentSnippet = getContentSnippet(el); +// Keeps the compose box anchored under (or above, if it would overflow) its target +// element. Reads the box's actual rendered height instead of a fixed guess, since the +// textarea auto-grows as the user types; without that the box could grow past the +// bottom of the viewport once the flip decision was made against a stale height. +function positionComposeBox(el, box) { const r = el.getBoundingClientRect(); - const mg = 8, bw = 240, bh = 118; + const mg = 8, bw = 240; + const bh = box.offsetHeight || 118; let left = r.left; let top = r.bottom + mg; if (left + bw > window.innerWidth - mg) @@ -190,21 +207,61 @@ function openCompose(el, selector) { top = r.top - bh - mg; if (top < mg) top = mg; + box.style.left = `${left}px`; + box.style.top = `${top}px`; +} +// Grows the textarea with its content up to a max height, then lets it scroll +// internally (harmless now that the window scroll listener no longer closes the +// compose box on incidental scrolls originating inside loupe's own UI). +function autoGrowTextarea(ta) { + ta.style.height = `${TEXTAREA_MIN_HEIGHT}px`; + const next = Math.max(Math.min(ta.scrollHeight, TEXTAREA_MAX_HEIGHT), TEXTAREA_MIN_HEIGHT); + ta.style.height = `${next}px`; + ta.style.overflowY = ta.scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; +} +function openCompose(el, selector, restore) { + if (composeBox) { + composeBox.remove(); + composeBox = null; + } + const contentSnippet = getContentSnippet(el); const box = document.createElement('div'); box.setAttribute('data-loupe-compose', '1'); - box.style.cssText = `position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:${left}px;top:${top}px;`; + box.style.cssText = 'position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:-9999px;top:-9999px;'; const lbl = document.createElement('div'); lbl.style.cssText = 'font-size:10px;color:#94a3b8;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; lbl.textContent = selector || 'element'; box.appendChild(lbl); const ta = document.createElement('textarea'); - ta.style.cssText = 'background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:56px;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;'; + ta.style.cssText = `background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:${TEXTAREA_MIN_HEIGHT}px;overflow-y:hidden;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;`; ta.placeholder = 'Type note, Enter to add...'; box.appendChild(ta); const hint = document.createElement('div'); hint.style.cssText = 'font-size:10px;color:#475569;line-height:1.3;'; hint.textContent = 'Enter: add Shift+Enter: newline Cmd+Enter: add & send all Esc: cancel'; box.appendChild(hint); + function persistDraft() { + try { + const draft = { + selector, + contentSnippet, + text: ta.value, + selectionStart: ta.selectionStart, + selectionEnd: ta.selectionEnd, + focused: document.activeElement === ta, + }; + sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } + catch (_e) { } + } + ta.addEventListener('input', () => { + autoGrowTextarea(ta); + positionComposeBox(el, box); + persistDraft(); + }); + ta.addEventListener('keyup', persistDraft); + ta.addEventListener('click', persistDraft); + ta.addEventListener('blur', persistDraft); ta.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter' && e.shiftKey) @@ -232,7 +289,23 @@ function openCompose(el, selector) { }); document.body.appendChild(box); composeBox = box; - ta.focus(); + persistCurrentDraft = persistDraft; + if (restore) { + ta.value = restore.text; + autoGrowTextarea(ta); + const len = ta.value.length; + try { + ta.setSelectionRange(Math.min(restore.selectionStart, len), Math.min(restore.selectionEnd, len)); + } + catch (_e) { } + if (restore.focus) + ta.focus(); + } + else { + ta.focus(); + } + positionComposeBox(el, box); + persistDraft(); } function closeCompose() { if (composeBox) { @@ -242,6 +315,8 @@ function closeCompose() { if (selOv) selOv.style.display = 'none'; selectedEl = null; + persistCurrentDraft = null; + clearPersistedDraft(); } /* ---- rAF tick ---- */ function tick() { @@ -251,6 +326,9 @@ function tick() { if (selectedEl && selOv && selOv.style.display !== 'none') { placeOv(selOv, selectedEl); } + if (composeBox && selectedEl) { + positionComposeBox(selectedEl, composeBox); + } requestAnimationFrame(tick); } requestAnimationFrame(tick); @@ -283,9 +361,66 @@ function checkAnchor(threadId, selector, contentSnippet) { } return { threadId, selector: null, orphaned: true }; } +/* ---- draft restoration across iframe reloads ---- */ +// Reopens an in-progress compose box after the shell reloads the artifact iframe +// (e.g. the agent just edited the file on disk). Reuses checkAnchor, the same +// stale-selector-to-element machinery used for orphan detection elsewhere, so a +// draft survives even if the reload changed the DOM enough to shift the selector. +function restoreDraftOnLoad() { + let raw = null; + try { + raw = sessionStorage.getItem(DRAFT_KEY); + } + catch (_e) { + return; + } + if (!raw) + return; + let draft; + try { + draft = JSON.parse(raw); + } + catch (_e) { + clearPersistedDraft(); + return; + } + const anchor = checkAnchor('__draft__', draft.selector || null, draft.contentSnippet || null); + if (anchor.orphaned || !anchor.selector) { + clearPersistedDraft(); + return; + } + const target = document.querySelector(anchor.selector); + if (!target) { + clearPersistedDraft(); + return; + } + selectedEl = target; + showSelOv(target); + openCompose(target, anchor.selector, { + text: draft.text, + selectionStart: draft.selectionStart, + selectionEnd: draft.selectionEnd, + focus: draft.focused, + }); +} +restoreDraftOnLoad(); +// Final flush before the iframe document is torn down, e.g. by the shell reloading +// the artifact after the agent edits the file on disk. Covers any state change that +// happened after the last keyup/click/blur persist (rare, but cheap to cover). +window.addEventListener('pagehide', () => { persistCurrentDraft?.(); }); /* ---- postMessage from parent shell ---- */ -window.addEventListener('scroll', () => { if (composeBox) - closeCompose(); }, true); +// Scroll events do not bubble, but this listener is capture-phase on window, so it +// still fires for scrolls inside descendants, including the compose textarea's own +// internal overflow scrolling once a note grows past its visible height. Ignore those +// entirely. A real page scroll no longer closes the compose box either: it stays +// anchored to its target element every frame via tick(), closing on scroll just to +// lose the user's draft is itself the bug we're fixing. +window.addEventListener('scroll', (e) => { + if (isLoupeEl(e.target)) + return; + if (composeBox && selectedEl) + positionComposeBox(selectedEl, composeBox); +}, true); window.addEventListener('message', (e) => { if (!e.data || typeof e.data !== 'object') return; diff --git a/src/browser/client-sdk.ts b/src/browser/client-sdk.ts index 8d39983..8d2e440 100644 --- a/src/browser/client-sdk.ts +++ b/src/browser/client-sdk.ts @@ -15,6 +15,39 @@ type MarkerState = 'done' | 'agent-last' | 'interim' | 'queued' | 'user-last' | let markers: Record = {}; let presenceWorking = false; +// Draft persistence: survives the artifact iframe being torn down and reloaded +// (the shell reloads the artifact whenever the underlying file changes on disk). +// sessionStorage is scoped to the tab + origin, not to a specific document instance, +// so it survives `artifactFrame.src = ...` reassignment as long as the iframe stays +// same-origin with the shell, which it is here (both served by the loupe server). +const DRAFT_KEY = 'loupe:compose-draft:' + key; +const TEXTAREA_MIN_HEIGHT = 56; +const TEXTAREA_MAX_HEIGHT = 200; + +interface PersistedDraft { + selector: string; + contentSnippet: string; + text: string; + selectionStart: number; + selectionEnd: number; + focused: boolean; +} + +interface ComposeRestoreOptions { + text: string; + selectionStart: number; + selectionEnd: number; + focus: boolean; +} + +// Set while a compose box is open, to flush its latest state to sessionStorage +// on pagehide (fired just before the iframe document is torn down on reload). +let persistCurrentDraft: (() => void) | null = null; + +function clearPersistedDraft(): void { + try { sessionStorage.removeItem(DRAFT_KEY); } catch (_e) {} +} + // Inject pulse keyframe once at module load. const _pulseStyle = document.createElement('style'); _pulseStyle.textContent = '@keyframes loupe-pulse{0%,100%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 0 rgba(245,158,11,0.55)}50%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 5px rgba(245,158,11,0)}} .loupe-awaiting-pulse{animation:loupe-pulse 1.4s ease-in-out infinite;}'; @@ -174,22 +207,42 @@ function showSelOv(el: Element): void { } /* ---- inline compose ---- */ -function openCompose(el: Element, selector: string): void { - if (composeBox) { composeBox.remove(); composeBox = null; } - const contentSnippet = getContentSnippet(el); +// Keeps the compose box anchored under (or above, if it would overflow) its target +// element. Reads the box's actual rendered height instead of a fixed guess, since the +// textarea auto-grows as the user types; without that the box could grow past the +// bottom of the viewport once the flip decision was made against a stale height. +function positionComposeBox(el: Element, box: HTMLDivElement): void { const r = el.getBoundingClientRect(); - const mg = 8, bw = 240, bh = 118; + const mg = 8, bw = 240; + const bh = box.offsetHeight || 118; let left = r.left; let top = r.bottom + mg; if (left + bw > window.innerWidth - mg) left = window.innerWidth - bw - mg; if (left < mg) left = mg; if (top + bh > window.innerHeight - mg) top = r.top - bh - mg; if (top < mg) top = mg; + box.style.left = `${left}px`; + box.style.top = `${top}px`; +} + +// Grows the textarea with its content up to a max height, then lets it scroll +// internally (harmless now that the window scroll listener no longer closes the +// compose box on incidental scrolls originating inside loupe's own UI). +function autoGrowTextarea(ta: HTMLTextAreaElement): void { + ta.style.height = `${TEXTAREA_MIN_HEIGHT}px`; + const next = Math.max(Math.min(ta.scrollHeight, TEXTAREA_MAX_HEIGHT), TEXTAREA_MIN_HEIGHT); + ta.style.height = `${next}px`; + ta.style.overflowY = ta.scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; +} + +function openCompose(el: Element, selector: string, restore?: ComposeRestoreOptions): void { + if (composeBox) { composeBox.remove(); composeBox = null; } + const contentSnippet = getContentSnippet(el); const box = document.createElement('div'); box.setAttribute('data-loupe-compose', '1'); - box.style.cssText = `position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:${left}px;top:${top}px;`; + box.style.cssText = 'position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:-9999px;top:-9999px;'; const lbl = document.createElement('div'); lbl.style.cssText = 'font-size:10px;color:#94a3b8;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; @@ -197,7 +250,7 @@ function openCompose(el: Element, selector: string): void { box.appendChild(lbl); const ta = document.createElement('textarea'); - ta.style.cssText = 'background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:56px;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;'; + ta.style.cssText = `background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:${TEXTAREA_MIN_HEIGHT}px;overflow-y:hidden;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;`; ta.placeholder = 'Type note, Enter to add...'; box.appendChild(ta); @@ -206,6 +259,29 @@ function openCompose(el: Element, selector: string): void { hint.textContent = 'Enter: add Shift+Enter: newline Cmd+Enter: add & send all Esc: cancel'; box.appendChild(hint); + function persistDraft(): void { + try { + const draft: PersistedDraft = { + selector, + contentSnippet, + text: ta.value, + selectionStart: ta.selectionStart, + selectionEnd: ta.selectionEnd, + focused: document.activeElement === ta, + }; + sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } catch (_e) {} + } + + ta.addEventListener('input', () => { + autoGrowTextarea(ta); + positionComposeBox(el, box); + persistDraft(); + }); + ta.addEventListener('keyup', persistDraft); + ta.addEventListener('click', persistDraft); + ta.addEventListener('blur', persistDraft); + ta.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Enter' && e.shiftKey) return; @@ -229,13 +305,27 @@ function openCompose(el: Element, selector: string): void { document.body.appendChild(box); composeBox = box; - ta.focus(); + persistCurrentDraft = persistDraft; + + if (restore) { + ta.value = restore.text; + autoGrowTextarea(ta); + const len = ta.value.length; + try { ta.setSelectionRange(Math.min(restore.selectionStart, len), Math.min(restore.selectionEnd, len)); } catch (_e) {} + if (restore.focus) ta.focus(); + } else { + ta.focus(); + } + positionComposeBox(el, box); + persistDraft(); } function closeCompose(): void { if (composeBox) { composeBox.remove(); composeBox = null; } if (selOv) selOv.style.display = 'none'; selectedEl = null; + persistCurrentDraft = null; + clearPersistedDraft(); } /* ---- rAF tick ---- */ @@ -246,6 +336,9 @@ function tick(): void { if (selectedEl && selOv && selOv.style.display !== 'none') { placeOv(selOv, selectedEl); } + if (composeBox && selectedEl) { + positionComposeBox(selectedEl, composeBox); + } requestAnimationFrame(tick); } requestAnimationFrame(tick); @@ -276,8 +369,52 @@ function checkAnchor(threadId: string, selector: string | null, contentSnippet: return { threadId, selector: null, orphaned: true }; } +/* ---- draft restoration across iframe reloads ---- */ +// Reopens an in-progress compose box after the shell reloads the artifact iframe +// (e.g. the agent just edited the file on disk). Reuses checkAnchor, the same +// stale-selector-to-element machinery used for orphan detection elsewhere, so a +// draft survives even if the reload changed the DOM enough to shift the selector. +function restoreDraftOnLoad(): void { + let raw: string | null = null; + try { raw = sessionStorage.getItem(DRAFT_KEY); } catch (_e) { return; } + if (!raw) return; + + let draft: PersistedDraft; + try { draft = JSON.parse(raw) as PersistedDraft; } catch (_e) { clearPersistedDraft(); return; } + + const anchor = checkAnchor('__draft__', draft.selector || null, draft.contentSnippet || null); + if (anchor.orphaned || !anchor.selector) { clearPersistedDraft(); return; } + + const target = document.querySelector(anchor.selector); + if (!target) { clearPersistedDraft(); return; } + + selectedEl = target; + showSelOv(target); + openCompose(target, anchor.selector, { + text: draft.text, + selectionStart: draft.selectionStart, + selectionEnd: draft.selectionEnd, + focus: draft.focused, + }); +} +restoreDraftOnLoad(); + +// Final flush before the iframe document is torn down, e.g. by the shell reloading +// the artifact after the agent edits the file on disk. Covers any state change that +// happened after the last keyup/click/blur persist (rare, but cheap to cover). +window.addEventListener('pagehide', () => { persistCurrentDraft?.(); }); + /* ---- postMessage from parent shell ---- */ -window.addEventListener('scroll', () => { if (composeBox) closeCompose(); }, true); +// Scroll events do not bubble, but this listener is capture-phase on window, so it +// still fires for scrolls inside descendants, including the compose textarea's own +// internal overflow scrolling once a note grows past its visible height. Ignore those +// entirely. A real page scroll no longer closes the compose box either: it stays +// anchored to its target element every frame via tick(), closing on scroll just to +// lose the user's draft is itself the bug we're fixing. +window.addEventListener('scroll', (e: Event) => { + if (isLoupeEl(e.target)) return; + if (composeBox && selectedEl) positionComposeBox(selectedEl, composeBox); +}, true); window.addEventListener('message', (e: MessageEvent) => { if (!e.data || typeof e.data !== 'object') return; From bfcbcdf7968e8d0c35a0b91d2796889b7277eb4f Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:27:29 +0200 Subject: [PATCH 2/9] fix(client-sdk): position before focus, track focus robustly, sync plugin version Reviewer follow-up on fc56a09: - Move positionComposeBox before both ta.focus() calls in openCompose. Focusing the box while it still sat at its off-screen placeholder risked the browser scrolling it into view, a visible jump on every open and a spurious scroll event during draft restore. autoGrowTextarea still runs first on restore so the height is settled before the position (and its top/bottom flip) is computed. - Stop deriving the persisted "focused" flag from document.activeElement at blur/pagehide time, it has already moved on by then and would always read as unfocused. Track it in a closure variable driven by explicit focus/blur listeners instead. The blur-to-false transition is deferred by one macrotask and cancelled if pagehide fires first, so a blur that is only a side effect of the iframe document tearing down for reload does not zero out the flag before the final draft snapshot is taken; ambiguous cases resolve in favor of restoring focus rather than silently dropping it. - Bump .claude-plugin/plugin.json to 1.0.1 to match package.json, since Claude Code reads the plugin version from that file to decide whether there is an update to deliver. --- .claude-plugin/plugin.json | 2 +- dist/browser/client-sdk.js | 65 +++++++++++++++++++++++++++++++++---- src/browser/client-sdk.ts | 66 ++++++++++++++++++++++++++++++++++---- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1929799..ea19047 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "loupe", "description": "Interactive HTML artifact review loop for local coding agents. Opens agent-generated HTML in the browser with an annotation layer; the user clicks elements, selects text, and sends targeted feedback that returns to the agent as JSON.", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "CodeWithAgents", "url": "https://github.com/codewithagents" diff --git a/dist/browser/client-sdk.js b/dist/browser/client-sdk.js index 16ad3b9..8ca1bb7 100644 --- a/dist/browser/client-sdk.js +++ b/dist/browser/client-sdk.js @@ -18,6 +18,11 @@ const TEXTAREA_MAX_HEIGHT = 200; // Set while a compose box is open, to flush its latest state to sessionStorage // on pagehide (fired just before the iframe document is torn down on reload). let persistCurrentDraft = null; +// Cancels the currently open box's pending "focus was lost" flip (see the blur +// handler in openCompose). pagehide calls this before persisting, so a blur that +// only happened because the iframe document is mid-teardown never gets a chance +// to record "not focused" before we capture the final draft state. +let cancelPendingFocusFlip = null; function clearPersistedDraft() { try { sessionStorage.removeItem(DRAFT_KEY); @@ -224,6 +229,7 @@ function openCompose(el, selector, restore) { composeBox.remove(); composeBox = null; } + cancelPendingFocusFlip?.(); const contentSnippet = getContentSnippet(el); const box = document.createElement('div'); box.setAttribute('data-loupe-compose', '1'); @@ -240,6 +246,20 @@ function openCompose(el, selector, restore) { hint.style.cssText = 'font-size:10px;color:#475569;line-height:1.3;'; hint.textContent = 'Enter: add Shift+Enter: newline Cmd+Enter: add & send all Esc: cancel'; box.appendChild(hint); + // Whether the textarea is focused, tracked explicitly instead of read from + // document.activeElement at persist time. By the time any blur handler (or the + // pagehide handler below) runs, activeElement has already moved on to whatever + // took focus (or to document.body), so reading it there would always report + // "not focused", even when the blur is really just an artifact of the iframe + // document being torn down for reload rather than a deliberate focus change. + let isFocused = restore ? restore.focus : true; + let focusFlipTimer = null; + function cancelFocusFlip() { + if (focusFlipTimer !== null) { + window.clearTimeout(focusFlipTimer); + focusFlipTimer = null; + } + } function persistDraft() { try { const draft = { @@ -248,7 +268,7 @@ function openCompose(el, selector, restore) { text: ta.value, selectionStart: ta.selectionStart, selectionEnd: ta.selectionEnd, - focused: document.activeElement === ta, + focused: isFocused, }; sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } @@ -261,7 +281,26 @@ function openCompose(el, selector, restore) { }); ta.addEventListener('keyup', persistDraft); ta.addEventListener('click', persistDraft); - ta.addEventListener('blur', persistDraft); + ta.addEventListener('focus', () => { + cancelFocusFlip(); + isFocused = true; + persistDraft(); + }); + ta.addEventListener('blur', () => { + // Defer the flip to false by one macrotask rather than applying it immediately. + // A genuine blur (e.g. focus moving to the parent shell's own sidebar input) has + // nothing racing it, so the flip lands a moment later as normal. A blur that is + // just a side effect of this document unloading for reload is followed by + // pagehide first, which cancels this timer, so isFocused (and the persisted + // draft) still says "focused" when we come back. Ambiguous cases resolve in + // favor of restoring focus, since losing it silently is the worse outcome. + cancelFocusFlip(); + focusFlipTimer = window.setTimeout(() => { + focusFlipTimer = null; + isFocused = false; + persistDraft(); + }, 0); + }); ta.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter' && e.shiftKey) @@ -290,9 +329,18 @@ function openCompose(el, selector, restore) { document.body.appendChild(box); composeBox = box; persistCurrentDraft = persistDraft; + cancelPendingFocusFlip = cancelFocusFlip; + // Settle the height first (it affects the top/bottom flip math), then place the + // box at its final position, and only then focus anything. Focusing an element + // still parked at its off-screen placeholder position risks the browser trying to + // scroll it into view, which would visibly jump the page (and, on restore, fire a + // spurious scroll event during init). if (restore) { ta.value = restore.text; autoGrowTextarea(ta); + } + positionComposeBox(el, box); + if (restore) { const len = ta.value.length; try { ta.setSelectionRange(Math.min(restore.selectionStart, len), Math.min(restore.selectionEnd, len)); @@ -304,7 +352,6 @@ function openCompose(el, selector, restore) { else { ta.focus(); } - positionComposeBox(el, box); persistDraft(); } function closeCompose() { @@ -315,6 +362,8 @@ function closeCompose() { if (selOv) selOv.style.display = 'none'; selectedEl = null; + cancelPendingFocusFlip?.(); + cancelPendingFocusFlip = null; persistCurrentDraft = null; clearPersistedDraft(); } @@ -405,9 +454,13 @@ function restoreDraftOnLoad() { } restoreDraftOnLoad(); // Final flush before the iframe document is torn down, e.g. by the shell reloading -// the artifact after the agent edits the file on disk. Covers any state change that -// happened after the last keyup/click/blur persist (rare, but cheap to cover). -window.addEventListener('pagehide', () => { persistCurrentDraft?.(); }); +// the artifact after the agent edits the file on disk. Cancel any pending "focus was +// lost" flip first, so a blur that only fired as a side effect of this same teardown +// does not get to record "not focused" before we capture the final draft state. +window.addEventListener('pagehide', () => { + cancelPendingFocusFlip?.(); + persistCurrentDraft?.(); +}); /* ---- postMessage from parent shell ---- */ // Scroll events do not bubble, but this listener is capture-phase on window, so it // still fires for scrolls inside descendants, including the compose textarea's own diff --git a/src/browser/client-sdk.ts b/src/browser/client-sdk.ts index 8d2e440..023a88b 100644 --- a/src/browser/client-sdk.ts +++ b/src/browser/client-sdk.ts @@ -44,6 +44,12 @@ interface ComposeRestoreOptions { // on pagehide (fired just before the iframe document is torn down on reload). let persistCurrentDraft: (() => void) | null = null; +// Cancels the currently open box's pending "focus was lost" flip (see the blur +// handler in openCompose). pagehide calls this before persisting, so a blur that +// only happened because the iframe document is mid-teardown never gets a chance +// to record "not focused" before we capture the final draft state. +let cancelPendingFocusFlip: (() => void) | null = null; + function clearPersistedDraft(): void { try { sessionStorage.removeItem(DRAFT_KEY); } catch (_e) {} } @@ -238,6 +244,7 @@ function autoGrowTextarea(ta: HTMLTextAreaElement): void { function openCompose(el: Element, selector: string, restore?: ComposeRestoreOptions): void { if (composeBox) { composeBox.remove(); composeBox = null; } + cancelPendingFocusFlip?.(); const contentSnippet = getContentSnippet(el); const box = document.createElement('div'); @@ -259,6 +266,19 @@ function openCompose(el: Element, selector: string, restore?: ComposeRestoreOpti hint.textContent = 'Enter: add Shift+Enter: newline Cmd+Enter: add & send all Esc: cancel'; box.appendChild(hint); + // Whether the textarea is focused, tracked explicitly instead of read from + // document.activeElement at persist time. By the time any blur handler (or the + // pagehide handler below) runs, activeElement has already moved on to whatever + // took focus (or to document.body), so reading it there would always report + // "not focused", even when the blur is really just an artifact of the iframe + // document being torn down for reload rather than a deliberate focus change. + let isFocused = restore ? restore.focus : true; + let focusFlipTimer: number | null = null; + + function cancelFocusFlip(): void { + if (focusFlipTimer !== null) { window.clearTimeout(focusFlipTimer); focusFlipTimer = null; } + } + function persistDraft(): void { try { const draft: PersistedDraft = { @@ -267,7 +287,7 @@ function openCompose(el: Element, selector: string, restore?: ComposeRestoreOpti text: ta.value, selectionStart: ta.selectionStart, selectionEnd: ta.selectionEnd, - focused: document.activeElement === ta, + focused: isFocused, }; sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch (_e) {} @@ -280,7 +300,26 @@ function openCompose(el: Element, selector: string, restore?: ComposeRestoreOpti }); ta.addEventListener('keyup', persistDraft); ta.addEventListener('click', persistDraft); - ta.addEventListener('blur', persistDraft); + ta.addEventListener('focus', () => { + cancelFocusFlip(); + isFocused = true; + persistDraft(); + }); + ta.addEventListener('blur', () => { + // Defer the flip to false by one macrotask rather than applying it immediately. + // A genuine blur (e.g. focus moving to the parent shell's own sidebar input) has + // nothing racing it, so the flip lands a moment later as normal. A blur that is + // just a side effect of this document unloading for reload is followed by + // pagehide first, which cancels this timer, so isFocused (and the persisted + // draft) still says "focused" when we come back. Ambiguous cases resolve in + // favor of restoring focus, since losing it silently is the worse outcome. + cancelFocusFlip(); + focusFlipTimer = window.setTimeout(() => { + focusFlipTimer = null; + isFocused = false; + persistDraft(); + }, 0); + }); ta.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation(); @@ -306,17 +345,26 @@ function openCompose(el: Element, selector: string, restore?: ComposeRestoreOpti document.body.appendChild(box); composeBox = box; persistCurrentDraft = persistDraft; + cancelPendingFocusFlip = cancelFocusFlip; + // Settle the height first (it affects the top/bottom flip math), then place the + // box at its final position, and only then focus anything. Focusing an element + // still parked at its off-screen placeholder position risks the browser trying to + // scroll it into view, which would visibly jump the page (and, on restore, fire a + // spurious scroll event during init). if (restore) { ta.value = restore.text; autoGrowTextarea(ta); + } + positionComposeBox(el, box); + + if (restore) { const len = ta.value.length; try { ta.setSelectionRange(Math.min(restore.selectionStart, len), Math.min(restore.selectionEnd, len)); } catch (_e) {} if (restore.focus) ta.focus(); } else { ta.focus(); } - positionComposeBox(el, box); persistDraft(); } @@ -324,6 +372,8 @@ function closeCompose(): void { if (composeBox) { composeBox.remove(); composeBox = null; } if (selOv) selOv.style.display = 'none'; selectedEl = null; + cancelPendingFocusFlip?.(); + cancelPendingFocusFlip = null; persistCurrentDraft = null; clearPersistedDraft(); } @@ -400,9 +450,13 @@ function restoreDraftOnLoad(): void { restoreDraftOnLoad(); // Final flush before the iframe document is torn down, e.g. by the shell reloading -// the artifact after the agent edits the file on disk. Covers any state change that -// happened after the last keyup/click/blur persist (rare, but cheap to cover). -window.addEventListener('pagehide', () => { persistCurrentDraft?.(); }); +// the artifact after the agent edits the file on disk. Cancel any pending "focus was +// lost" flip first, so a blur that only fired as a side effect of this same teardown +// does not get to record "not focused" before we capture the final draft state. +window.addEventListener('pagehide', () => { + cancelPendingFocusFlip?.(); + persistCurrentDraft?.(); +}); /* ---- postMessage from parent shell ---- */ // Scroll events do not bubble, but this listener is capture-phase on window, so it From 2beb8facc2a08491404996a94b983f27b6e94b59 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:42:22 +0200 Subject: [PATCH 3/9] chore: mark dist/ as generated in .gitattributes dist/ is committed on purpose: this is a Claude Code plugin distributed as a GitHub repo, and plugin installs fetch repo contents with no build step, so a gitignored dist/ would break every install. Mark it as generated output anyway so GitHub collapses it in PR diffs and excludes it from language stats, and so local git diff treats it as binary instead of flooding output with compiled JS. Also normalize line endings for this cross-platform CLI. --- .gitattributes | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f82cdf6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings for this cross-platform CLI. +* text=auto eol=lf + +# dist/ is compiled output, committed on purpose: this is a Claude Code plugin +# distributed as a GitHub repo, and plugin installs fetch repo contents with no +# build step. If dist/ is not committed, every install breaks. Do not gitignore it. +# +# It is still generated code, not something a human edits by hand, so mark it as +# such: GitHub collapses it in PR diffs and excludes it from language stats, and +# -diff keeps it out of local `git diff` output too. CI still enforces that dist/ +# matches a fresh build of src/, see .github/workflows/ci.yml. +dist/** linguist-generated=true -diff From 4b3b15c4863ea297119f60cd351158f85d6c6669 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:42:32 +0200 Subject: [PATCH 4/9] ci: fix workflow reproducibility and modernize Node matrix npm install ignored package-lock.json, so installs were not reproducible; switch to npm ci. The bare push trigger combined with pull_request meant every push to a branch with an open PR ran the whole matrix twice; restrict push to main so PR validation comes only from the pull_request trigger. Node 18 is end of life and inconsistent with devDependencies that already target @types/node ^26, so replace the [18, 20] matrix with [22, 24, 26] and add a package.json engines field to match. Run npm test instead of a bare node --test so CI and local runs use the same command, and cache npm installs via actions/setup-node's cache option. --- .github/workflows/ci.yml | 13 ++++++++++--- package.json | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00b9782..8bcaf21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,13 @@ name: CI on: push: + branches: [main] pull_request: + branches: [main] + +# Minimal permissions: the workflow only checks out code and runs local scripts. +permissions: + contents: read jobs: test: @@ -10,13 +16,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20] + node: [22, 24, 26] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - - run: npm install + cache: npm + - run: npm ci - run: npm run typecheck - run: npm run build - - run: node --test + - run: npm test diff --git a/package.json b/package.json index 2af8e2a..3239fe8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "test": "node --test test/loop.test.js" }, "license": "MIT", + "engines": { + "node": ">=22" + }, "homepage": "https://github.com/codewithagents/loupe", "repository": { "type": "git", From 338286888bfefa51aed750896cc8253e8c77f923 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:42:47 +0200 Subject: [PATCH 5/9] ci: add dist drift gate Hiding dist/ from PR diffs via .gitattributes makes stale compiled output more likely to slip through unnoticed. Add a job that does a clean install, rebuilds from src/, and fails with git diff --exit-code if dist/ changed, telling the contributor to run npm run build and commit the result. Verified locally that git diff --exit-code still reports a nonzero exit status for a dirty dist/ despite the -diff attribute, it only changes the diff from a text hunk to "Binary files ... differ", not the exit code. Runs on a single Node version, there is no reason to rebuild the same TypeScript across the whole matrix just to compare output. --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bcaf21..b9705af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,28 @@ jobs: - run: npm run typecheck - run: npm run build - run: npm test + + # dist/ is committed on purpose (see .gitattributes), which also hides it from + # PR diffs. That makes stale compiled output easy to miss, so enforce the + # invariant here instead: a clean rebuild of src/ must produce byte-identical + # dist/ output. Runs on a single Node version only, there is nothing to gain + # from comparing build output across the matrix. + dist-drift: + name: Verify dist/ matches a fresh build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - name: Fail if dist/ is stale + run: | + if git diff --exit-code -- dist/; then + echo "dist/ matches a fresh build of src/." + else + echo "::error::dist/ does not match a fresh build of src/. Run 'npm run build' locally and commit the updated dist/ output." + exit 1 + fi From 2d945dd4fa3b87ad7c4f85317661cc24d6e0ac69 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:42:56 +0200 Subject: [PATCH 6/9] chore: reset version baseline to 1.0.0 for release-please package.json and .claude-plugin/plugin.json were manually bumped to 1.0.1 on this branch, but release-please is about to take over version management and computes its own bump from the conventional-commit history. Reset both back to 1.0.0, last released version, staying equal to each other, so release-please opens the 1.0.1 bump PR instead of us pre-empting it by hand. --- .claude-plugin/plugin.json | 2 +- package-lock.json | 7 +++++-- package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ea19047..1929799 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "loupe", "description": "Interactive HTML artifact review loop for local coding agents. Opens agent-generated HTML in the browser with an annotation layer; the user clicks elements, selects text, and sends targeted feedback that returns to the agent as JSON.", - "version": "1.0.1", + "version": "1.0.0", "author": { "name": "CodeWithAgents", "url": "https://github.com/codewithagents" diff --git a/package-lock.json b/package-lock.json index e356ac3..fd11bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codewithagents/loupe", - "version": "1.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codewithagents/loupe", - "version": "1.0.1", + "version": "1.0.0", "license": "MIT", "bin": { "loupe": "dist/cli.js" @@ -14,6 +14,9 @@ "devDependencies": { "@types/node": "^26.3.0", "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@types/node": { diff --git a/package.json b/package.json index 3239fe8..0de78bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codewithagents/loupe", - "version": "1.0.1", + "version": "1.0.0", "type": "module", "description": "Interactive HTML feedback loop for Claude Code agents", "main": "dist/cli.js", From fdebd408c6b902d3da21f825a74c4d9c2f97167a Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:47:05 +0200 Subject: [PATCH 7/9] chore: add release-please config and manifest Seeds release-please with bootstrap-sha bfbd3e9223b5500eb79de9fbda9c5d7a20e64666, the current origin/main head, so it only considers commits after it: the two fix commits on this branch plus the ci and chore commits that do not affect version. component loupe with tag-separator -- produces tags like loupe--v1.0.1. extra-files keeps .claude-plugin/plugin.json in sync via jsonpath, since Claude Code reads that file's version to decide whether to deliver an update to consumers. The manifest is seeded with 1.0.0 explicitly, an empty manifest would make release-please fall back to the node release-type default of 0.1.0. Schema values transcribed from an upstream-verified source, not independently re-derived here. Two are worth flagging for anyone reviewing this: component is honored by release-please but is absent from its published JSON schema, an editor or schema validator may flag it as unknown even though it is correct, and none of this can be exercised locally, release-please only runs once this reaches GitHub. --- .release-please-manifest.json | 1 + release-please-config.json | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..484cfe6 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{ ".": "1.0.0" } diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..ade873a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "bfbd3e9223b5500eb79de9fbda9c5d7a20e64666", + "packages": { + ".": { + "release-type": "node", + "component": "loupe", + "tag-separator": "--", + "extra-files": [ + { "type": "json", "path": ".claude-plugin/plugin.json", "jsonpath": "$.version" } + ] + } + } +} From e35c5d1db33a4f775e350761b5a14ea05bcc07c7 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:47:11 +0200 Subject: [PATCH 8/9] ci: add release-please workflow Runs googleapis/release-please-action on push to main, plus a manual workflow_dispatch trigger for recovery, pinned by commit SHA with a version comment to match the sibling openapi-zod-ts repo's supply-chain practice (same pin, same action version). Grants contents, issues, and pull-requests write, all three are part of the action's documented basic configuration. This repo has no publish step: it ships as a Claude Code plugin distributed via the GitHub repo itself, not via npm, so unlike the sibling monorepo's release workflow there is nothing to publish once release-please tags a release. The job only opens the release PR and, on merge, creates the tag, GitHub Release, and changelog. --- .github/workflows/release.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b3186e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +# This repo ships as a Claude Code plugin, distributed via the GitHub repo +# itself (see .claude-plugin/), not published to npm. release-please's job +# here is only to open the release PR, then on merge create the git tag, +# the GitHub Release, and the changelog. There is no publish step, unlike +# the sibling openapi-zod-ts monorepo's release workflow. +name: Release Please + +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: write # create tags and releases + issues: write # release-please's documented basic configuration requires this + pull-requests: write # create and update release PRs + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json From 4bf84436ff8ce0c89c1223b19d62b1d7013c2b6e Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:47:17 +0200 Subject: [PATCH 9/9] docs: update README Node.js requirement to 22+ The CI matrix and package.json engines field already moved to Node [22, 24, 26] in an earlier commit on this branch; the README's install and requirements sections still said Node.js 18+, which is now factually wrong. No release-please-related hand-bump instructions were found in README.md or SKILL.md, so no changes were needed there. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f1886db..6510cd0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Interactive HTML feedback loop for Claude Code agents. The agent opens an HTML f 2. `/plugin install loupe@codewithagents` 3. If prompted, run `/reload-plugins` to activate. -The skill is invoked as `/loupe:loupe`. Requires Node.js 18+ and has zero runtime dependencies. +The skill is invoked as `/loupe:loupe`. Requires Node.js 22+ and has zero runtime dependencies. ## Development @@ -51,7 +51,7 @@ node dist/cli.js poll /path/to/your/file.html --reply "I've updated the layout." ## Requirements -- Node.js 18 or newer. +- Node.js 22 or newer. - Zero runtime npm dependencies. ## License