Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/live-accept.mjs
1/**2* CLI helper: deterministic accept/discard of variant sessions.3*4* Usage:5* node live-accept.mjs --id SESSION_ID --discard6* node live-accept.mjs --id SESSION_ID --variant N7*8* For discard: removes the entire variant wrapper and restores the original.9* For accept: replaces the wrapper with the chosen variant's content. If the10* session had a colocated <style> block, it's preserved with carbonize markers11* for a background agent to integrate into the project's CSS.12*13* Output: JSON to stdout.14*/1516import fs from 'node:fs';17import path from 'node:path';18import { isGeneratedFile } from './is-generated.mjs';1920const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];2122// ---------------------------------------------------------------------------23// CLI24// ---------------------------------------------------------------------------2526export async function acceptCli() {27const args = process.argv.slice(2);2829if (args.includes('--help') || args.includes('-h')) {30console.log(`Usage: node live-accept.mjs [options]3132Deterministic accept/discard for live variant sessions.3334Modes:35--discard Remove variants, restore original36--variant N Accept variant N, discard the rest3738Required:39--id SESSION_ID Session ID of the variant wrapper4041Output (JSON):42{ handled, file, carbonize }`);43process.exit(0);44}4546const id = argVal(args, '--id');47const variantNum = argVal(args, '--variant');48const paramValuesRaw = argVal(args, '--param-values');49const isDiscard = args.includes('--discard');5051if (!id) { console.error('Missing --id'); process.exit(1); }52if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }5354let paramValues = null;55if (paramValuesRaw) {56try { paramValues = JSON.parse(paramValuesRaw); }57catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept58}5960// Find the file containing this session's markers61const found = findSessionFile(id, process.cwd());62if (!found) {63console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));64process.exit(0);65}6667const { file: targetFile, content, lines } = found;68const relFile = path.relative(process.cwd(), targetFile);6970// Bail if the session lives in a generated file. The agent manually wrote71// the wrapper there for preview, and is responsible for writing the72// accepted variant to true source (or cleaning up on discard). See73// "Handle fallback" in live.md.74if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {75console.log(JSON.stringify({76handled: false,77mode: 'fallback',78file: relFile,79hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',80}));81process.exit(0);82}8384if (isDiscard) {85const result = handleDiscard(id, lines, targetFile);86console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));87} else {88const result = handleAccept(id, variantNum, lines, targetFile, paramValues);89// Single-line attention-grabber when cleanup is required. The full90// five-step checklist lives in reference/live.md (loaded once per91// session); repeating it per-event would waste tokens.92if (result.carbonize) {93result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';94}95console.log(JSON.stringify({ handled: true, file: relFile, ...result }));96}97}9899// ---------------------------------------------------------------------------100// Discard101// ---------------------------------------------------------------------------102103function handleDiscard(id, lines, targetFile) {104const block = findMarkerBlock(id, lines);105if (!block) return { handled: false, error: 'Markers not found' };106107const original = extractOriginal(lines, block);108const isJsx = detectCommentSyntax(targetFile).open === '{/*';109const replaceRange = expandReplaceRange(block, lines, isJsx);110111// Restore at the line we're actually replacing FROM, not the marker line.112// For JSX wrappers the marker comments live INSIDE the outer `<div>`, so113// `block.start` sits 2 spaces deeper than the original element. Using that114// as the deindent base would push the restored content 2 spaces too far115// right on every JSX/TSX session. `replaceRange.start` is the outer wrapper116// line, which is at the original element's indent for both HTML and JSX.117const indent = lines[replaceRange.start].match(/^(\s*)/)[1];118const restored = deindentContent(original, indent);119120const newLines = [121...lines.slice(0, replaceRange.start),122...restored,123...lines.slice(replaceRange.end + 1),124];125fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');126return {};127}128129// ---------------------------------------------------------------------------130// Accept131// ---------------------------------------------------------------------------132133function handleAccept(id, variantNum, lines, targetFile, paramValues) {134const block = findMarkerBlock(id, lines);135if (!block) return { handled: false, error: 'Markers not found' };136137const commentSyntax = detectCommentSyntax(targetFile);138const isJsx = commentSyntax.open === '{/*';139// Anchor indent on the line we're replacing FROM (the outer wrapper),140// not on `block.start` — for JSX that's the marker comment 2 spaces141// deeper than the original element. See handleDiscard for the full142// rationale.143const replaceRange = expandReplaceRange(block, lines, isJsx);144const indent = lines[replaceRange.start].match(/^(\s*)/)[1];145146// Extract the chosen variant's inner content147const variantContent = extractVariant(lines, block, variantNum);148if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };149150// Extract CSS block if present151const cssContent = extractCss(lines, block, id);152153// Check if carbonizing is needed:154// - CSS block exists, OR155// - variant HTML contains helper classes/attributes that need cleanup156const variantText = variantContent.join('\n');157const hasHelperAttrs = variantText.includes('data-impeccable-variant');158const needsCarbonize = !!(cssContent || hasHelperAttrs);159160// Build the replacement161const restored = deindentContent(variantContent, indent);162const replacement = [];163164if (cssContent) {165replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);166// JSX targets need the CSS body wrapped in a template literal so that the167// `{` and `}` in CSS rules don't get parsed as JSX expressions.168replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));169// Re-indent CSS content to match170for (const cssLine of cssContent) {171replacement.push(indent + cssLine.trimStart());172}173replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));174if (paramValues && Object.keys(paramValues).length > 0) {175// Preserve the user's knob positions for the carbonize-cleanup agent176// to bake into the final CSS when it collapses scoped rules.177replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);178}179replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);180}181182// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the183// carbonize CSS block working visually by re-wrapping the accepted content184// in a data-impeccable-variant="N" div with `display: contents` (so layout185// isn't affected). The carbonize agent strips this attribute + wrapper when186// it moves the CSS to a proper stylesheet.187//188// Style attribute syntax has to follow the host file's flavor — JSX files189// need the object form, otherwise React 19 throws "Failed to set indexed190// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.191if (cssContent) {192const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';193replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');194replacement.push(...restored);195replacement.push(indent + '</div>');196} else {197replacement.push(...restored);198}199200const newLines = [201...lines.slice(0, replaceRange.start),202...replacement,203...lines.slice(replaceRange.end + 1),204];205fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');206207return { carbonize: needsCarbonize };208}209210// ---------------------------------------------------------------------------211// Parsing helpers212// ---------------------------------------------------------------------------213214/**215* Find the start/end marker lines for a session.216* Returns { start, end } (0-indexed line numbers) or null.217*/218function findMarkerBlock(id, lines) {219let start = -1;220let end = -1;221const startPattern = 'impeccable-variants-start ' + id;222const endPattern = 'impeccable-variants-end ' + id;223224for (let i = 0; i < lines.length; i++) {225if (start === -1 && lines[i].includes(startPattern)) start = i;226if (lines[i].includes(endPattern)) { end = i; break; }227}228229return (start !== -1 && end !== -1) ? { start, end } : null;230}231232/**233* Compute the line range to REPLACE (vs. just the marker range to extract234* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE235* the `<div data-impeccable-variants="ID">` outer wrapper so the picked236* element's JSX slot keeps a single child — a Fragment `<></>` would have237* solved the multi-sibling case but failed inside `asChild` / cloneElement238* parents with "Invalid prop supplied to React.Fragment".239*240* That means the marker block is enclosed by the wrapper `<div>` opener241* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We242* walk back to the opener and forward to the closer so accept/discard243* remove the entire scaffold, not just the inner markers.244*245* Marker lines themselves stay where they were so extractOriginal /246* extractVariant / extractCss continue to walk the same range.247*/248function expandReplaceRange(block, lines, isJsx) {249if (!isJsx) return { start: block.start, end: block.end };250251let { start, end } = block;252253// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.254// The attr may sit on a continuation line of a multi-line opening tag, so255// also walk to the line that actually contains `<div`.256for (let i = start - 1; i >= Math.max(0, start - 12); i--) {257if (/data-impeccable-variants=/.test(lines[i])) {258let opener = i;259while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;260start = opener;261break;262}263}264265// Walk forward to the matching `</div>` by div-depth tracking from the266// wrapper opener. Operate on JOINED text instead of per-line: a267// multi-line self-closing JSX `<div\n className="spacer"\n/>` would268// fool per-line regex tracking (the `<div` line matches openRe but the269// `/>` line never matches selfCloseRe since it needs `<div` on the same270// line). That left depth permanently over-counted and the wrapper's271// outer `</div>` orphaned after accept/discard. Single regex with272// `[^>]*?` (which spans newlines in JS) handles either form correctly.273const joined = lines.slice(start).join('\n');274// Match either `<div … />` (self-close, group 1 is `/`), `<div … >`275// (open, group 1 is empty), or `</div>`.276const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;277let depth = 0;278let m;279while ((m = tagRe.exec(joined)) !== null) {280const isClose = m[0].startsWith('</');281const isSelfClose = !isClose && m[1] === '/';282if (isClose) depth--;283else if (!isSelfClose) depth++;284if (depth <= 0) {285// m.index is offset within `joined`; convert back to a file line.286const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;287const candidateEnd = start + linesBefore;288if (candidateEnd >= end) {289end = candidateEnd;290break;291}292}293}294295return { start, end };296}297298/**299* Join wrapper lines into a single string with `<style>` elements removed so300* marker matching and div-depth tracking aren't confused by:301* - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the302* HTML marker we're searching for303* - JSX self-closing `<style ... />` (no separate `</style>` to close on)304* - Same-line `<style>…</style>` blocks305* - Multi-line `<style>\n…\n</style>` blocks306*/307function stripStyleAndJoin(lines, block) {308const out = [];309let inStyle = false;310for (let i = block.start; i <= block.end; i++) {311let line = lines[i];312313if (!inStyle) {314// Strip any complete <style> elements on this line (self-closed or315// same-line-closed), including their body content.316line = line317.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')318.replace(/<style\b[^>]*\/\s*>/g, '');319320// If a <style> opener remains (multi-line body starts here), strip from321// the opener to end-of-line and flip into skip mode.322const openerIdx = line.search(/<style\b/);323if (openerIdx !== -1) {324line = line.slice(0, openerIdx);325inStyle = true;326}327out.push(line);328} else {329// In multi-line style body; drop everything until we see </style>.330const closeIdx = line.search(/<\/style\s*>/);331if (closeIdx !== -1) {332inStyle = false;333out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));334}335// else: skip line entirely336}337}338return out.join('\n');339}340341/**342* Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,343* handling nested same-tag elements via depth counting. `attrMatch` is a344* regex source fragment that must appear inside the opener tag.345* Returns the inner string (may be empty), or null if not found.346*/347function extractInnerByAttr(text, attrMatch) {348const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');349const openMatch = text.match(openerRe);350if (!openMatch) return null;351352const tagName = openMatch[1];353const innerStart = openMatch.index + openMatch[0].length;354355// Match any opener or closer of this tag name after innerStart.356// (Does not match self-closing <TAG … />, which doesn't contribute to depth.)357const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');358tagRe.lastIndex = innerStart;359360let depth = 1;361let m;362while ((m = tagRe.exec(text))) {363const isClose = m[0].startsWith('</');364const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);365if (isClose) {366depth--;367if (depth === 0) return text.slice(innerStart, m.index);368} else if (!isSelfClose) {369depth++;370}371}372return null;373}374375/**376* Extract the original element content from within the variant wrapper.377* Returns an array of lines.378*/379function extractOriginal(lines, block) {380const text = stripStyleAndJoin(lines, block);381const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');382if (inner === null) return [];383return inner.split('\n');384}385386/**387* Extract a specific variant's inner content (stripping the wrapper div).388* Returns an array of lines, or null if not found.389*/390function extractVariant(lines, block, variantNum) {391const text = stripStyleAndJoin(lines, block);392const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');393if (inner === null) return null;394const result = inner.split('\n');395// Collapse a lone empty leading/trailing line (common after string splice).396while (result.length > 1 && result[0].trim() === '') result.shift();397while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();398return result.length > 0 ? result : null;399}400401/**402* Extract the colocated <style> block content (between the style tags).403* Returns an array of CSS lines, or null if no style block found.404*405* Handles three shapes of `<style data-impeccable-css="ID" ...>`:406* 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).407* 2. Same-line open+close: `<style>...</style>` — return the inner content.408* 3. Multi-line: `<style>` on one line, `</style>` on a later line — return409* the lines between them.410*/411function extractCss(lines, block, id) {412const styleAttr = 'data-impeccable-css="' + id + '"';413let inStyle = false;414const content = [];415416for (let i = block.start; i <= block.end; i++) {417const line = lines[i];418419if (!inStyle && line.includes(styleAttr)) {420// Self-closing: nothing to carbonize.421if (/<style\b[^>]*\/\s*>/.test(line)) return null;422// Same-line open + close: extract inner text.423const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);424if (sameLine) {425const inner = stripJsxTemplateWrap(sameLine[1]);426return inner.length > 0 ? inner.split('\n') : null;427}428inStyle = true;429continue; // skip the <style> opening tag430}431432if (inStyle) {433// Detect </style> anywhere on the line — JSX template-literal closes434// (`}</style>`) put the close mid-line, and we don't want to absorb the435// template-literal punctuation as CSS content.436const closeIdx = line.indexOf('</style>');437if (closeIdx !== -1) break;438content.push(line);439}440}441442if (content.length === 0) return null;443return stripJsxTemplateLines(content);444}445446/**447* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a448* `<style>` element in a JSX/TSX file. The agent may write the wrap with449* `{` and `}` directly attached to the `<style>` tags, on their own lines,450* or attached to the first/last CSS lines — all three are JSX-legal.451*452* Stripping is required because handleAccept re-wraps the CSS itself when453* carbonizing. Without this, two consecutive accepts (or a previously-454* accepted variants block being carbonized) would produce nested455* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".456*/457function stripJsxTemplateLines(content) {458const out = content.slice();459460// Drop any leading blank lines so we don't miss a `{` line buried below461// them; same for trailing.462while (out.length > 0 && out[0].trim() === '') out.shift();463while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();464if (out.length === 0) return null;465466// Leading `{`: own line, or attached to the first CSS line.467const firstTrim = out[0].trimStart();468if (firstTrim === '{`') {469out.shift();470} else if (firstTrim.startsWith('{`')) {471const idx = out[0].indexOf('{`');472out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);473if (out[0].trim() === '') out.shift();474}475if (out.length === 0) return null;476477// Trailing `` ` `` `}`: own line, or attached to the last CSS line.478const lastIdx = out.length - 1;479const lastTrim = out[lastIdx].trimEnd();480if (lastTrim === '`}') {481out.pop();482} else if (lastTrim.endsWith('`}')) {483const text = out[lastIdx];484const idx = text.lastIndexOf('`}');485out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);486if (out[lastIdx].trim() === '') out.pop();487}488489return out.length > 0 ? out : null;490}491492function stripJsxTemplateWrap(text) {493const lines = text.split('\n');494const stripped = stripJsxTemplateLines(lines);495return stripped ? stripped.join('\n') : '';496}497498/**499* De-indent content that was indented by live-wrap.mjs.500* The wrap script adds `indent + ' '` (4 extra spaces) to each line.501* We restore to just `indent` level.502*/503function deindentContent(contentLines, baseIndent) {504// Find the minimum indentation in the content to determine how much was added505let minIndent = Infinity;506for (const line of contentLines) {507if (line.trim() === '') continue;508const leadingSpaces = line.match(/^(\s*)/)[1].length;509minIndent = Math.min(minIndent, leadingSpaces);510}511if (minIndent === Infinity) minIndent = 0;512513// Strip the extra indentation and re-add base indent514return contentLines.map(line => {515if (line.trim() === '') return '';516return baseIndent + line.slice(minIndent);517});518}519520function detectCommentSyntax(filePath) {521const ext = path.extname(filePath).toLowerCase();522if (ext === '.jsx' || ext === '.tsx') {523return { open: '{/*', close: '*/}' };524}525return { open: '<!--', close: '-->' };526}527528// ---------------------------------------------------------------------------529// File search (find the file containing session markers)530// ---------------------------------------------------------------------------531532function findSessionFile(id, cwd) {533const marker = 'impeccable-variants-start ' + id;534const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];535const seen = new Set();536537for (const dir of searchDirs) {538const absDir = path.join(cwd, dir);539if (!fs.existsSync(absDir)) continue;540const result = searchDir(absDir, marker, seen, 0);541if (result) {542const content = fs.readFileSync(result, 'utf-8');543return { file: result, content, lines: content.split('\n') };544}545}546return null;547}548549function searchDir(dir, query, seen, depth) {550if (depth > 5) return null;551let realDir;552try { realDir = fs.realpathSync(dir); } catch { return null; }553if (seen.has(realDir)) return null;554seen.add(realDir);555556let entries;557try { entries = fs.readdirSync(dir, { withFileTypes: true }); }558catch { return null; }559560for (const entry of entries) {561if (!entry.isFile()) continue;562if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;563const filePath = path.join(dir, entry.name);564try {565const content = fs.readFileSync(filePath, 'utf-8');566if (content.includes(query)) return filePath;567} catch { /* skip */ }568}569570for (const entry of entries) {571if (!entry.isDirectory()) continue;572if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;573const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);574if (result) return result;575}576577return null;578}579580// ---------------------------------------------------------------------------581// Utilities582// ---------------------------------------------------------------------------583584function argVal(args, flag) {585const idx = args.indexOf(flag);586return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;587}588589// Auto-execute when run directly590const _running = process.argv[1];591if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {592acceptCli();593}594595export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };596