mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
588ad042bf | ||
|
|
839fbe5d4a | ||
|
|
40d7edc073 | ||
|
|
b226c71de7 |
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[alias]
|
||||
xtask = "run --quiet --package xtask --"
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# The oracle replays goldens recorded from a POSIX checkout, and a finding's
|
||||
# snippet carries the fixture's own bytes, so these files have to arrive with
|
||||
# LF on every platform. `-text` disables end-of-line conversion outright, which
|
||||
# is also safe for any binary that lands under these trees.
|
||||
tests/fixtures/** -text
|
||||
tests/oracle/** -text
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
+6
-185
@@ -23,7 +23,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
core: ${{ steps.plan.outputs.core }}
|
||||
rust: ${{ steps.plan.outputs.rust }}
|
||||
detector: ${{ steps.plan.outputs.detector }}
|
||||
live: ${{ steps.plan.outputs.live }}
|
||||
framework: ${{ steps.plan.outputs.framework }}
|
||||
@@ -93,22 +92,13 @@ jobs:
|
||||
if: needs.changes.outputs.framework == 'true'
|
||||
run: bun run test:framework
|
||||
|
||||
- name: Rebuild browser detector
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: bun run build:browser
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
# `bun run build:extension` runs `cargo xtask bundle`: the rule core
|
||||
# compiled to wasm plus the page JS in browser-bundle/.
|
||||
- name: Install the pinned toolchain
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: rustup show && rustup target add wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
|
||||
- name: Install wasm-pack
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: cargo install wasm-pack --locked
|
||||
|
||||
- name: Build extension
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: bun run build:extension
|
||||
@@ -121,131 +111,18 @@ jobs:
|
||||
run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox
|
||||
|
||||
- name: Verify generated tracked outputs
|
||||
# extension/detector/ is gitignored (built by `cargo xtask bundle`);
|
||||
# it stays listed so a stray tracked copy shows up here.
|
||||
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector
|
||||
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js extension/detector
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: impeccable-build-node-${{ matrix.node-version }}
|
||||
name: impeccable-dist-node-${{ matrix.node-version }}
|
||||
# Ship the packaged zips, not the unpacked Firefox staging tree.
|
||||
path: |
|
||||
dist/
|
||||
!dist/extension-firefox/
|
||||
retention-days: 7
|
||||
|
||||
# The Rust workspace: the engine binary, the rule core, and every crate
|
||||
# behind them. Everything builds from source with no downloads.
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# rust-toolchain.toml names the channel; `rustup show` installs it.
|
||||
# Never override the toolchain here.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --all-targets
|
||||
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
# The engine ships a windows-x64 binary (release-engine.yml), so the
|
||||
# workspace has to build and pass its own tests there. Tests that need a
|
||||
# browser or the oracle skip when those are absent.
|
||||
rust-windows:
|
||||
runs-on: windows-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --workspace --all-targets
|
||||
- run: cargo test --workspace --no-fail-fast
|
||||
|
||||
# Behavior gate: replays the tests/oracle/ goldens against a release build
|
||||
# of the engine from THIS checkout (so a PR is judged on its own source,
|
||||
# not on the last published binary). Without this job the oracle only ever
|
||||
# runs on developer laptops: tests/oracle.test.mjs skips cleanly when no
|
||||
# binary is present, so the default suite is silent about it on CI.
|
||||
oracle:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine from source
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Replay oracle goldens
|
||||
env:
|
||||
IMPECCABLE_BIN: ${{ github.workspace }}/target/release/impeccable
|
||||
run: node tests/oracle/run.mjs
|
||||
|
||||
# Release-order guard (triage decision D4). Verifies that the engine release for
|
||||
# the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256
|
||||
# AND the five @impeccable/cli-<os>-<arch> npm platform packages — before a skill
|
||||
# release/merge that depends on them. The launcher, npm shim, and
|
||||
# `impeccable install` all dead-end without those assets.
|
||||
#
|
||||
# continue-on-error is a release-time toggle: until the first engine release is
|
||||
# published, the assets cannot exist and this job would block
|
||||
# every PR. It emits a loud ::warning instead. Once v<ENGINE_VERSION> is live,
|
||||
# flip `continue-on-error` to false so a MIS-ORDERED release (skill/CLI ahead of
|
||||
# the engine) fails CI. release.mjs already hard-fails `release:skill`/`release:cli`.
|
||||
engine-release-ready:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check engine release assets for pinned ENGINE_VERSION
|
||||
id: check
|
||||
continue-on-error: true
|
||||
run: node scripts/check-engine-release.mjs
|
||||
|
||||
- name: Annotate missing engine release
|
||||
if: steps.check.outcome != 'success'
|
||||
run: |
|
||||
echo "::warning title=Engine release not ready::The engine release for v$(cat ENGINE_VERSION) is not fully published (engine-v$(cat ENGINE_VERSION) release) and/or the @impeccable/cli-<os>-<arch> npm platform packages. Releasing the skill/CLI (or merging) now would dead-end the launcher, the npm shim, and impeccable install. Expected until the first engine release exists; after that, publish the engine + platform packages and flip this job's continue-on-error to false so a mis-ordered release fails CI."
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-matrix
|
||||
@@ -280,16 +157,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run remote CLI E2E smoke
|
||||
run: bun run test:cli-remote-e2e
|
||||
|
||||
@@ -345,16 +212,6 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run live E2E tests
|
||||
run: bun run test:live-e2e
|
||||
env:
|
||||
@@ -431,16 +288,6 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run live E2E tests
|
||||
run: bun run test:live-e2e
|
||||
env:
|
||||
@@ -513,19 +360,6 @@ jobs:
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
|
||||
- name: Build the engine
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run accept cleanup regression
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: |
|
||||
@@ -590,19 +424,6 @@ jobs:
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
|
||||
- name: Build the engine
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run Svelte adapter DeepSeek sweep
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: bun run test:live-svelte-adapter-deepseek
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
name: release-engine
|
||||
# Builds the engine binary for every supported target and publishes them, with
|
||||
# sha256 sidecars, as the GitHub Release `engine-v<X>` on this repo. That
|
||||
# release is what the launcher (skill/scripts/impeccable), the npm shim
|
||||
# (cli/bin/cli.js), `impeccable install`, and `bun run fetch:engine` download.
|
||||
#
|
||||
# Trigger: `bun run release:engine` (scripts/release.mjs) verifies
|
||||
# ENGINE_VERSION, the npm platform-package pins, and a clean tree, then
|
||||
# pushes the tag. Third-party actions are pinned to commit SHAs so a
|
||||
# moved tag cannot swap the code this workflow runs.
|
||||
on:
|
||||
push:
|
||||
tags: ['engine-v*']
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, target: aarch64-apple-darwin, short: darwin-arm64 }
|
||||
# No Intel runner: GitHub retired macos-13. Apple's toolchain builds
|
||||
# x86_64 on an arm64 host natively once the target is installed.
|
||||
- { os: macos-14, target: x86_64-apple-darwin, short: darwin-x64 }
|
||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, short: linux-x64 }
|
||||
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, short: linux-arm64, cross: true }
|
||||
- { os: windows-latest, target: x86_64-pc-windows-msvc, short: windows-x64 }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Check the tag matches ENGINE_VERSION
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
want="engine-v$(tr -d '[:space:]' < ENGINE_VERSION)"
|
||||
[ "$GITHUB_REF_NAME" = "$want" ] || { echo "tag $GITHUB_REF_NAME != $want"; exit 1; }
|
||||
# rust-toolchain.toml names the channel; `rustup show` installs it.
|
||||
# Never override the toolchain here.
|
||||
- name: Install the pinned toolchain
|
||||
shell: bash
|
||||
run: rustup show && rustup target add ${{ matrix.target }}
|
||||
- if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- if: matrix.cross
|
||||
run: cargo install cross --locked
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: ${{ matrix.cross && 'cross' || 'cargo' }} build --release -p impeccable --target ${{ matrix.target }}
|
||||
- name: Smoke the binary
|
||||
if: ${{ !matrix.cross }}
|
||||
shell: bash
|
||||
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: impeccable-${{ matrix.short }}
|
||||
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with: { path: artifacts }
|
||||
- name: Lay out release assets with checksums
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p out
|
||||
for d in artifacts/impeccable-*; do
|
||||
short=$(basename "$d" | sed 's/^impeccable-//')
|
||||
f=$(ls "$d" | head -1)
|
||||
case "$short" in windows-*) dest="out/impeccable-$short.exe" ;; *) dest="out/impeccable-$short" ;; esac
|
||||
cp "$d/$f" "$dest"
|
||||
(cd out && sha256sum "$(basename "$dest")" > "$(basename "$dest").sha256")
|
||||
done
|
||||
ls -la out
|
||||
- name: Publish the GitHub Release
|
||||
env: { GH_TOKEN: "${{ github.token }}" }
|
||||
# No --clobber: a published asset is immutable. A re-run against an
|
||||
# existing release fails on the first existing asset instead of
|
||||
# silently replacing a binary and its sidecar hash.
|
||||
run: |
|
||||
set -e
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "impeccable engine $tag" \
|
||||
--notes "Prebuilt impeccable engine binaries ($tag). The launcher, the npm shim and impeccable install download these on first run. Docs: https://impeccable.style" out/* || \
|
||||
gh release upload "$tag" out/* --repo "$GITHUB_REPOSITORY"
|
||||
-12
@@ -13,17 +13,10 @@ build/
|
||||
# can copy them into tmp git repos and assert is-generated behavior.
|
||||
!tests/framework-fixtures/**/dist/
|
||||
!tests/framework-fixtures/**/dist/**
|
||||
# Same for the oracle workspaces: live-html carries a dist/generated.html
|
||||
# that the generated-file cases point at.
|
||||
!tests/oracle/workspaces/**/dist/
|
||||
!tests/oracle/workspaces/**/dist/**
|
||||
|
||||
# Build artifacts
|
||||
*.log
|
||||
|
||||
# Cargo (the Rust workspace; Cargo.lock IS tracked, it pins the engine build)
|
||||
/target/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -90,11 +83,6 @@ src/lib/impeccable/__runtime.js
|
||||
# Extension build artifacts
|
||||
extension/detector/
|
||||
|
||||
# Engine binaries: fetched per platform (scripts/fetch-engine.mjs), never tracked.
|
||||
# The launcher next to them (skill/scripts/impeccable) is the tracked file.
|
||||
skill/scripts/bin/
|
||||
**/skills/impeccable/scripts/bin/
|
||||
|
||||
# Legacy design context (pre-v3.1, auto-migrated to PRODUCT.md by load-context.mjs)
|
||||
.impeccable.md
|
||||
# Note: PRODUCT.md and DESIGN.md are INTENTIONALLY tracked in this repo —
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,31 +285,10 @@ function resolveEnvContextDir(cwd) {
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetPath(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
return findUniqueBareTarget(cwd, targetPath) || abs;
|
||||
}
|
||||
|
||||
function findUniqueBareTarget(cwd, targetPath) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
|
||||
const rel = path.relative(absCwd, abs);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const segments = rel.split(path.sep).filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const name = segments[0];
|
||||
const repoRoot = findMonorepoRoot(absCwd);
|
||||
if (!repoRoot) return null;
|
||||
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
return path.resolve(repoRoot, matches[0].path);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = resolveTargetPath(cwd, targetPath);
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
@@ -1209,19 +1188,13 @@ async function cli() {
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const resolvedTargetPath = targetProvided
|
||||
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
|
||||
: null;
|
||||
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(
|
||||
process.cwd(),
|
||||
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||
);
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -1335,6 +1308,11 @@ function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
|
||||
@@ -189,12 +189,6 @@ Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -315,11 +309,6 @@ async function detectCli() {
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
let hadOperationalFailure = false;
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
@@ -330,22 +319,11 @@ async function detectCli() {
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
@@ -358,21 +336,14 @@ async function detectCli() {
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
@@ -401,7 +372,7 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
@@ -417,11 +388,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
@@ -432,33 +399,24 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -475,10 +433,6 @@ async function detectCli() {
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
@@ -489,10 +443,10 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user