diff --git a/source/skills/impeccable/reference/live.md b/source/skills/impeccable/reference/live.md index 53b64a21a..6f7a1e1de 100644 --- a/source/skills/impeccable/reference/live.md +++ b/source/skills/impeccable/reference/live.md @@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible ### Step 3: Signal completion +Include `--file` so the browser can fetch variants directly if the dev server lacks HMR: + ```bash -npx impeccable poll --reply EVENT_ID done +npx impeccable poll --reply EVENT_ID done --file RELATIVE_PATH ``` +The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`). + ## Handle Accept The event contains: `{id, variantId}`. diff --git a/src/live/browser.js b/src/live/browser.js index 9d208ede6..02dad6343 100644 --- a/src/live/browser.js +++ b/src/live/browser.js @@ -701,6 +701,76 @@ } } + /** + * No-HMR fallback: fetch the raw source file from the live server, + * parse it, extract the variant wrapper, and inject it into the live DOM. + * This works even when the dev server caches HTML (Bun, static servers). + */ + function injectVariantsFromSource(filePath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); + fetch(url) + .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) + .then(html => { + // Parse the raw source HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!srcWrapper) { + console.error('[impeccable] Variant wrapper not found in source file.'); + return; + } + + // Find the original element in the live DOM. + // The original is inside the wrapper in the source. We find the + // corresponding element in the live DOM by matching the first child's + // tag + classes from the original snapshot. + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + // Find by tag + exact class match + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + } + + if (!liveEl) { + console.error('[impeccable] Could not find original element in live DOM.'); + return; + } + + // Replace the live element with the full wrapper from source + const wrapper = srcWrapper.cloneNode(true); + liveEl.parentElement.replaceChild(wrapper, liveEl); + + // Update state: count variants, show the first one + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + visibleVariant = 1; + showVariantInDOM(sessionId, 1); + + // Update selectedElement to the visible variant's content + const visEl = wrapper.querySelector('[data-impeccable-variant="1"] > :first-child'); + selectedElement = visEl || wrapper.parentElement; + + state = 'CYCLING'; + updateBarContent('cycling'); + saveSession(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + }) + .catch(err => { + console.error('[impeccable] Failed to fetch source:', err); + showToast('Could not load variants. Try refreshing the page.', 5000); + }); + } + function cycleVariant(dir) { const next = visibleVariant + dir; if (next < 1 || next > arrivedVariants) return; @@ -833,12 +903,11 @@ }, 1800); return; } - // If variants haven't appeared in the DOM yet (no HMR), reload the - // page. The resumeSession logic will pick them up after reload. - if (arrivedVariants === 0 && expectedVariants > 0) { - console.log('[impeccable] Variants written but not in DOM (no HMR). Reloading...'); - saveSession(); - location.reload(); + // If variants haven't appeared in the DOM (no HMR), fetch the raw + // source file from the live server and inject variants into the DOM. + if (arrivedVariants === 0 && expectedVariants > 0 && msg.file) { + console.log('[impeccable] No HMR detected. Fetching variants from source file...'); + injectVariantsFromSource(msg.file, currentSessionId); return; } state = 'CYCLING'; diff --git a/src/live/poll.mjs b/src/live/poll.mjs index 4db5d298c..52859416c 100644 --- a/src/live/poll.mjs +++ b/src/live/poll.mjs @@ -45,15 +45,18 @@ Options: const info = readServerInfo(); const base = `http://localhost:${info.port}`; - // Reply mode: npx impeccable poll --reply [message] + // Reply mode: npx impeccable poll --reply [--file path] [message] const replyIdx = args.indexOf('--reply'); if (replyIdx !== -1) { const id = args[replyIdx + 1]; const status = args[replyIdx + 2] || 'done'; - const message = args[replyIdx + 3] || undefined; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; if (!id) { - console.error('Usage: npx impeccable poll --reply [message]'); + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); process.exit(1); } @@ -66,6 +69,7 @@ Options: id, type: status, message, + file: filePath, }), }); diff --git a/src/live/server.mjs b/src/live/server.mjs index 85b06ba9c..d2e2a5e7c 100644 --- a/src/live/server.mjs +++ b/src/live/server.mjs @@ -161,6 +161,26 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // Read a project file from disk (for no-HMR fallback: the browser fetches + // the raw source to inject variants when the dev server doesn't support HMR). + if (pathname === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + // Safety: must be within the project directory + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + try { + const content = fs.readFileSync(absPath, 'utf-8'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + } catch { + res.writeHead(404); res.end('File not found'); + } + return; + } + // --- Authenticated endpoints --- const token = url.searchParams.get('token'); diff --git a/src/live/wrap.mjs b/src/live/wrap.mjs index 591497973..f1c838cdd 100644 --- a/src/live/wrap.mjs +++ b/src/live/wrap.mjs @@ -105,7 +105,7 @@ The agent should insert variant HTML at insertLine.`); indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close, indent + '
', indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close, - indent + '
', + indent + '
', originalIndented, indent + '
', indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,