Compare commits

..
Author SHA1 Message Date
Paul Bakaus 53cb5c8cf7 Centralize live path glob matching
AI-assisted change prepared by Codex under scheduled architecture-simplification authorization from maintainer pbakaus.
2026-08-21 12:23:55 -07:00
300 changed files with 5208 additions and 7513 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `$impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,7 +42,6 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -257,153 +256,6 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1176,13 +1028,14 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : blankCommentsForMatchers(content, ext);
}) : content;
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1197,7 +1050,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (STYLESHEET_EXTS.has(ext)) {
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1225,8 +1078,7 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1237,8 +1089,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -721,19 +721,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -750,10 +751,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -2748,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,22 +103,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+23 -14
View File
@@ -33,8 +33,13 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
collectBootFindingGroups,
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -101,30 +106,34 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...bootFindings.config,
...bootFindings.buildPath,
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...bootFindings.surfaceBriefs,
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...bootFindings.projectRoots,
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...workspaceResult.findings,
];
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,46 +488,41 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
*/
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
nativePlatform: ctx.product
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: [],
designSidecar: checkDesignSidecar({
: []),
...checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
: []),
];
}
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,9 +238,10 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -268,11 +269,9 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
if (event && typeof event === 'object' && !event._instructions) {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,16 +181,8 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -944,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(realTarget, 'utf-8'); }
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1042,7 +1026,6 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,8 +11,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -47,17 +45,11 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -268,16 +260,36 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,8 +19,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -44,8 +42,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -220,6 +218,29 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
+2 -2
View File
@@ -1,9 +1,9 @@
{
"description": "Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.",
"description": "Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,7 +42,6 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -257,153 +256,6 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1176,13 +1028,14 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : blankCommentsForMatchers(content, ext);
}) : content;
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1197,7 +1050,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (STYLESHEET_EXTS.has(ext)) {
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1225,8 +1078,7 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1237,8 +1089,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -721,19 +721,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -750,10 +751,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -2748,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,22 +103,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+23 -14
View File
@@ -33,8 +33,13 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
collectBootFindingGroups,
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -101,30 +106,34 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...bootFindings.config,
...bootFindings.buildPath,
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...bootFindings.surfaceBriefs,
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...bootFindings.projectRoots,
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...workspaceResult.findings,
];
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,46 +488,41 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
*/
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
nativePlatform: ctx.product
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: [],
designSidecar: checkDesignSidecar({
: []),
...checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
: []),
];
}
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,9 +238,10 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -268,11 +269,9 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
if (event && typeof event === 'object' && !event._instructions) {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,16 +181,8 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -944,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(realTarget, 'utf-8'); }
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1042,7 +1026,6 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,8 +11,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -47,17 +45,11 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -268,16 +260,36 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,8 +19,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -44,8 +42,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -220,6 +218,29 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,7 +42,6 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -257,153 +256,6 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1176,13 +1028,14 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : blankCommentsForMatchers(content, ext);
}) : content;
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1197,7 +1050,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (STYLESHEET_EXTS.has(ext)) {
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1225,8 +1078,7 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1237,8 +1089,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -721,19 +721,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -750,10 +751,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -2748,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,22 +103,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+23 -14
View File
@@ -33,8 +33,13 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
collectBootFindingGroups,
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -101,30 +106,34 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...bootFindings.config,
...bootFindings.buildPath,
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...bootFindings.surfaceBriefs,
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...bootFindings.projectRoots,
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...workspaceResult.findings,
];
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,46 +488,41 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
*/
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
nativePlatform: ctx.product
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: [],
designSidecar: checkDesignSidecar({
: []),
...checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
: []),
];
}
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,9 +238,10 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -268,11 +269,9 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
if (event && typeof event === 'object' && !event._instructions) {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,16 +181,8 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -944,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(realTarget, 'utf-8'); }
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1042,7 +1026,6 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,8 +11,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -47,17 +45,11 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -268,16 +260,36 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,8 +19,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -44,8 +42,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -220,6 +218,29 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,7 +42,6 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -257,153 +256,6 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1176,13 +1028,14 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : blankCommentsForMatchers(content, ext);
}) : content;
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1197,7 +1050,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (STYLESHEET_EXTS.has(ext)) {
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1225,8 +1078,7 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1237,8 +1089,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -721,19 +721,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -750,10 +751,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -2748,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,22 +103,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+23 -14
View File
@@ -33,8 +33,13 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
collectBootFindingGroups,
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -101,30 +106,34 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...bootFindings.config,
...bootFindings.buildPath,
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...bootFindings.surfaceBriefs,
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...bootFindings.projectRoots,
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...workspaceResult.findings,
];
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,46 +488,41 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
*/
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
nativePlatform: ctx.product
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: [],
designSidecar: checkDesignSidecar({
: []),
...checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
: []),
];
}
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,9 +238,10 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -268,11 +269,9 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
if (event && typeof event === 'object' && !event._instructions) {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,16 +181,8 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -944,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(realTarget, 'utf-8'); }
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1042,7 +1026,6 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,8 +11,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -47,17 +45,11 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -268,16 +260,36 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,8 +19,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -44,8 +42,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -220,6 +218,29 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,7 +42,6 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -257,153 +256,6 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1176,13 +1028,14 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : blankCommentsForMatchers(content, ext);
}) : content;
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1197,7 +1050,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (STYLESHEET_EXTS.has(ext)) {
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1225,8 +1078,7 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1237,8 +1089,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -721,19 +721,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -750,10 +751,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -2748,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,22 +103,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+23 -14
View File
@@ -33,8 +33,13 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
collectBootFindingGroups,
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -101,30 +106,34 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...bootFindings.config,
...bootFindings.buildPath,
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...bootFindings.surfaceBriefs,
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...bootFindings.projectRoots,
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...workspaceResult.findings,
];
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,46 +488,41 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
*/
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
nativePlatform: ctx.product
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: [],
designSidecar: checkDesignSidecar({
: []),
...checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
: []),
];
}
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,9 +238,10 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -268,11 +269,9 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
if (event && typeof event === 'object' && !event._instructions) {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,16 +181,8 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -944,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(realTarget, 'utf-8'); }
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1042,7 +1026,6 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,8 +11,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -47,17 +45,11 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -268,16 +260,36 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,8 +19,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -44,8 +42,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -220,6 +218,29 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,7 +42,6 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -257,153 +256,6 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1176,13 +1028,14 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : blankCommentsForMatchers(content, ext);
}) : content;
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1197,7 +1050,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (STYLESHEET_EXTS.has(ext)) {
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1225,8 +1078,7 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1237,8 +1089,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -721,19 +721,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -750,10 +751,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -2748,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,22 +103,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+23 -14
View File
@@ -33,8 +33,13 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
collectBootFindingGroups,
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -101,30 +106,34 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...bootFindings.config,
...bootFindings.buildPath,
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...bootFindings.surfaceBriefs,
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...bootFindings.projectRoots,
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...workspaceResult.findings,
];
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,46 +488,41 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
*/
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
nativePlatform: ctx.product
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: [],
designSidecar: checkDesignSidecar({
: []),
...checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
: []),
];
}
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,9 +238,10 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
@@ -268,11 +269,9 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
if (event && typeof event === 'object' && !event._instructions) {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,16 +181,8 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -944,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(realTarget, 'utf-8'); }
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1042,7 +1026,6 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,8 +11,6 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -47,17 +45,11 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -268,16 +260,36 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,8 +19,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -44,8 +42,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -220,6 +218,29 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -773,22 +773,14 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1963,19 +1955,20 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative two-axis grid backgrounds drawn with hairline
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1992,10 +1985,13 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
if (hairlineCount >= 2 && hasPxCell) {
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: 'two-axis grid-line gradient background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
@@ -3990,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7285,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7347,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7644,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7774,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();

Some files were not shown because too many files have changed in this diff Show More