mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49571365a8 | ||
|
|
8056422d87 | ||
|
|
a735bc55cd | ||
|
|
01e5112127 | ||
|
|
2e8f8dfdae | ||
|
|
313d0748f2 | ||
|
|
26bb3d3af5 | ||
|
|
d07edadafb | ||
|
|
1a7ee36324 | ||
|
|
8e3926a3aa | ||
|
|
8522ce7e25 | ||
|
|
809976638d | ||
|
|
9a7d0fbc50 | ||
|
|
ba873f7599 | ||
|
|
7ddcd533a4 | ||
|
|
7426af446e | ||
|
|
a236137bc6 | ||
|
|
ddb609936a | ||
|
|
5444031942 | ||
|
|
067665cc7e | ||
|
|
8347d77f54 | ||
|
|
1b7da15b56 | ||
|
|
56f44523f7 | ||
|
|
5d4418e2dc | ||
|
|
abba4012ff | ||
|
|
e0a9d8e7d9 | ||
|
|
fccd91c6ac | ||
|
|
e5abceedc4 | ||
|
|
c29f30fa34 | ||
|
|
6360b27823 | ||
|
|
a66aefba80 | ||
|
|
77dd327080 | ||
|
|
ff1f15c7ad | ||
|
|
d2a9efb90f | ||
|
|
16a218e632 | ||
|
|
7b94585653 | ||
|
|
8d62b135fe | ||
|
|
611147a333 | ||
|
|
7d5c60d291 | ||
|
|
1f2c3f9d6b | ||
|
|
cf8f295dc3 | ||
|
|
665c51b903 |
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"description": "Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.",
|
||||
"description": "Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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']);
|
||||
|
||||
@@ -256,6 +257,153 @@ 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)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ 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',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
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
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,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 (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,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(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ 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 });
|
||||
|
||||
@@ -33,13 +33,8 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
collectBootFindingGroups,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
const bootFindings = collectBootFindingGroups(ctx, {
|
||||
absDesignPath,
|
||||
sidecarCandidates,
|
||||
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
targetCandidates: workspaceCandidates,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...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 }),
|
||||
...bootFindings.product,
|
||||
...bootFindings.nativePlatform,
|
||||
...bootFindings.designSidecar,
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...bootFindings.config,
|
||||
...bootFindings.buildPath,
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...bootFindings.surfaceBriefs,
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...bootFindings.projectRoots,
|
||||
...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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
* 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.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
export function collectBootFindingGroups(ctx, extras = {}) {
|
||||
if (!ctx) return {};
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
return {
|
||||
product: 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.
|
||||
...(ctx.product
|
||||
nativePlatform: ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
: [],
|
||||
designSidecar: checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
projectRoots: extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
|
||||
}
|
||||
|
||||
@@ -4902,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(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) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ 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 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid 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. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// 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.
|
||||
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;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,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);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,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);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(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
Reference in New Issue
Block a user