chore: update vendored skills and record provenance

humanizer 2.2.0 -> 2.9.1 (blader/humanizer, MIT): adds a Voice
Calibration section and a passive-voice pattern, reworks negative
parallelisms and em dashes. Version moved to metadata.version upstream.

impeccable 3.6.0 -> 4.0.4 (pbakaus/impeccable, Apache-2.0): the repo
tags the skill and the npm CLI separately, so 3.6.0 was a real release
and npm's 3.5.0 was never the comparison. Adds native-platform reference
briefs.

Each now carries an UPSTREAM file; bin/check-vendored.sh reports drift.
This commit is contained in:
naps62
2026-08-01 14:25:26 +00:00
parent 2f7f512cec
commit 9023ed3ef9
144 changed files with 28302 additions and 5511 deletions
@@ -3,9 +3,87 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
// On Windows, puppeteer's bundled Chrome lives in a user-writable cache
// directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security
// software or the GPU sandbox because it launches from an untrusted path.
// Chrome then crash-loops the GPU process, and each relaunch briefly flashes a
// compositor surface, the black window users report during `detect <url>`
// (issue #372). The system-installed Chrome runs from a trusted location with a
// healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware
// GPU, so contrast measurement is unaffected. Scope this to Windows only: other
// platforms do not have the bug, so they keep the pinned bundled build for
// consistent measurement across machines. Fall back to bundled when the switch
// fails (Chrome not installed, or channel resolution fails). If the bundled
// launch then also fails, surface the original system-Chrome error as the
// cause so the real failure is not lost.
async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) {
let channelError;
if (process.platform === 'win32') {
try {
return await puppeteer.default.launch({ channel: 'chrome', headless, args });
} catch (err) {
// System Chrome unavailable or unlaunchable; fall through to the bundled
// browser, but keep the error in case the fallback fails too.
channelError = err;
}
}
try {
return await puppeteer.default.launch({ headless, args });
} catch (err) {
if (channelError && err && err.cause === undefined) err.cause = channelError;
throw err;
}
}
// Reveal sweep + invisible-text measurement for the content-hidden-at-rest
// rule. Scrolls through the document with instant jumps (bypasses CSS
// scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers
// get every chance to fire, returns to the top, lets transitions settle,
// then measures how much text still renders invisible. A healthy
// reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal
// script died keeps most of its text at opacity 0.
async function measureContentHiddenAfterReveal(page) {
await page.evaluate(async () => {
const step = Math.max(200, Math.floor(window.innerHeight * 0.7));
const max = Math.max(
document.documentElement.scrollHeight || 0,
document.body?.scrollHeight || 0,
);
for (let y = 0; y <= max; y += step) {
window.scrollTo({ top: y, left: 0, behavior: 'instant' });
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40)));
}
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 700));
});
return page.evaluate(() => {
if (typeof window.impeccableMeasureHiddenText !== 'function') return null;
return window.impeccableMeasureHiddenText();
});
}
function serializeDesignSystemForBrowser(designSystem) {
if (!designSystem?.present) return null;
return {
present: true,
hasFonts: designSystem.hasFonts === true,
allowedFonts: Array.from(designSystem.allowedFonts || []),
hasColors: designSystem.hasColors === true,
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
.map(entry => entry?.color)
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b })),
hasRadii: designSystem.hasRadii === true,
allowedRadii: (designSystem.allowedRadii || [])
.map(entry => Number(entry?.px))
.filter(px => Number.isFinite(px)),
hasPillRadius: designSystem.hasPillRadius === true,
};
}
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
@@ -132,13 +210,26 @@ async function detectUrl(url, options = {}) {
phase: 'load',
ruleId: 'launch-browser',
target: url,
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
}, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs }));
const page = await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'new-page',
target: url,
}, () => browser.newPage());
// Uncaught exceptions and parse errors surface as pageerror events. The
// listener must attach before goto: a syntax error fires during the
// initial parse, long before the load event. Dedupe by message; a single
// broken loop can otherwise throw hundreds of identical errors.
const pageErrors = [];
if (options?.scriptErrors !== false) {
page.on('pageerror', (err) => {
const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160);
if (message && !pageErrors.includes(message)) pageErrors.push(message);
});
}
let results = [];
try {
await profileStepAsync(profile, {
@@ -163,17 +254,19 @@ async function detectUrl(url, options = {}) {
}
// Inject the browser detection script and collect results
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
}, () => page.evaluate((designSystem) => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
...(designSystem ? { designSystem } : {}),
};
}));
}, browserDesignSystem));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
@@ -192,9 +285,29 @@ async function detectUrl(url, options = {}) {
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
);
});
// Content invisible at rest: reveal sweep, then re-measure. Runs after
// the main scan (which must see the true at-rest state) and before the
// visual contrast fallback (the sweep restores scroll to the top).
if (options?.contentHidden !== false) {
const hiddenFindings = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'content-hidden-at-rest',
target: url,
}, async () => {
const measured = await measureContentHiddenAfterReveal(page);
return measured ? checkContentHiddenAtRest(measured) : [];
});
results.push(...hiddenFindings);
}
for (const message of pageErrors.slice(0, 3)) {
results.push({ id: 'script-error', snippet: message });
}
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
results.push(...visualFindings);
} finally {
@@ -213,7 +326,14 @@ async function detectUrl(url, options = {}) {
}, () => browser.close());
}
}
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
return results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return item;
});
}
async function createBrowserDetector(options = {}) {
@@ -224,7 +344,7 @@ async function createBrowserDetector(options = {}) {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
const browser = options.browser || await puppeteer.default.launch({
const browser = options.browser || await launchBrowser(puppeteer, {
headless: options.headless ?? true,
args: launchArgs,
});
@@ -249,4 +369,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
@@ -1,17 +1,23 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForPseudoStripe, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
// ---------------------------------------------------------------------------
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
// ---------------------------------------------------------------------------
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
const hasRounded = (line) =>
/\brounded(?:-\w+)?\b/.test(line.replace(/\brounded-none\b/g, ''));
const hasBorderRadius = (line) => /border-radius/i.test(line);
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
/** Strip HTML to plain text — drops script/style/comments/tags so
* content-text analyzers don't false-positive on code or CSS. */
function stripHtmlToText(html) {
@@ -35,31 +41,107 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
// CSS named colors whose channels are equal (achromatic). Anything outside
// this set falls through to the format parsers, and an unrecognized spelling
// stays non-neutral so a real accent is never skipped.
const NEUTRAL_COLOR_KEYWORDS = new Set([
'transparent', 'currentcolor',
'black', 'white', 'gray', 'grey', 'silver',
'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey',
'gainsboro', 'whitesmoke',
]);
function hexChannels(color) {
const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i);
if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)];
const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i);
if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16));
return null;
}
/**
* Split one box-shadow layer into top-level tokens.
*
* Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and
* `var(--x, 4px)` are each a single value, and splitting them on spaces would
* read their innards as separate lengths.
*/
function tokenizeShadowLayer(layer) {
const tokens = [];
let depth = 0;
let current = '';
for (const char of String(layer || '')) {
if (char === '(') depth++;
else if (char === ')') depth--;
else if (depth === 0 && /\s/.test(char)) {
if (current) tokens.push(current);
current = '';
continue;
}
current += char;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
if (current) tokens.push(current);
return tokens;
}
function lastMatch(text, re) {
const all = [...String(text || '').matchAll(re)];
return all.length ? all[all.length - 1] : null;
}
function isShadowLength(token) {
return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || ''));
}
/**
* Neutrality test for colors as written in source CSS.
*
* shared/color.mjs's isNeutralColor only parses the computed function forms a
* browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
* other spelling as chromatic so an unknown format is never silently skipped.
* That default is wrong for authored CSS, where `#000` and `black` are the
* normal spellings: calling it directly reports a plain black hairline as a
* colored stripe. Handle hex and named neutrals here, then defer.
*/
function isNeutralAuthoredColor(rawColor) {
const c = String(rawColor || '').trim().toLowerCase();
if (!c) return false;
if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true;
// Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs
// parses only the comma form a browser's getComputedStyle emits, so authored
// space-separated neutrals fell through it and reported as chromatic — the
// exemption this function exists for, missed. Normalize before delegating.
if (/^rgba?\(/i.test(c)) {
const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
if (channels) {
const values = [1, 2, 3].map((i) => Number(channels[i]));
return (Math.max(...values) - Math.min(...values)) < 30;
}
return isNeutralColor(c);
}
if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const channels = hexChannels(c);
if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30;
return false;
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
return isNeutralAuthoredColor(m[1]);
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -84,9 +166,12 @@ const REGEX_MATCHERS = [
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
test: (m) => {
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
return Boolean(m.overusedGoogleFont);
},
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
@@ -156,27 +241,6 @@ const REGEX_MATCHERS = [
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
@@ -226,15 +290,34 @@ const REGEX_ANALYZERS = [
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
},
// Em-dash overuse: 5+ em-dashes or "--" in body text content
// (occasional em-dash use in prose is fine; the pattern fires only
// when count crosses into AI-cadence territory).
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
// advisory (surfaced separately, never a failure, hook-skipped by default) and
// its threshold is deliberately conservative. Two gates must both hold:
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
// never fires, no matter how short.
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
// of body text, so a long article that uses eight across several thousand
// words is left alone while a short, dash-per-clause landing page is not.
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
//
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
// a model that writes `&mdash;`, `&#8212;`, or `&#x2014;` renders an em-dash
// the counter never saw. Decode the em-dash entities (named, zero-padded
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
// deliberately left alone: the rule counts em-dashes, and the literal ``
// was never counted either.
(content, filePath) => {
const text = stripHtmlToText(content);
const text = stripHtmlToText(content)
.replace(/&mdash;|&#0*8212;|&#x0*2014;/gi, '—');
let count = 0;
const re = /[—]|--(?=\S)/g;
while (re.exec(text) !== null) count++;
if (count < 5) return [];
if (count < EM_DASH_FLOOR) return [];
// Saturation gate: dashes must be dense in the prose, not sprinkled through
// a long document. textLength <= count * chars-per-dash means the density is
// at or above the threshold.
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
},
// Marketing buzzwords: SaaS phrase list
@@ -270,22 +353,6 @@ const REGEX_ANALYZERS = [
if (count === 0) return [];
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
},
// Numbered section markers (01 / 02 / 03 ...)
(content, filePath) => {
const text = stripHtmlToText(content);
const re = /\b(0[1-9]|1[0-2])\b/g;
const seen = new Set();
let m;
while ((m = re.exec(text)) !== null) seen.add(m[1]);
if (seen.size < 3) return [];
const sorted = [...seen].sort();
let sequential = 0;
for (let i = 1; i < sorted.length; i++) {
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
}
if (sequential < 2) return [];
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
},
// Aphoristic cadence: manufactured-contrast + short-rebuttal
(content, filePath) => {
const text = stripHtmlToText(content);
@@ -307,41 +374,143 @@ const REGEX_ANALYZERS = [
if (count < 3) return [];
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
// Dark glow / chromatic halo shadows (page-level). Shared scanner handles
// any color format, single-level var() resolution, zero-offset halos on
// any background, and text-shadow glows.
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
const hits = scanCssTextForGlow(content);
if (hits.length === 0) return [];
const lines = content.substring(0, hits[0].index).split('\n');
return [finding('dark-glow', filePath, hits[0].snippet, lines.length)];
},
// Radial-gradient background halo on a dark page (the gradient sibling
// of the dark-glow shadow tell).
(content, filePath) => {
const hits = scanCssTextForRadialHalo(content);
if (hits.length === 0) return [];
const lines = content.substring(0, hits[0].index).split('\n');
return [finding('radial-halo', filePath, hits[0].snippet, lines.length)];
},
// Auto-scrolling marquees (<marquee> or infinite horizontal loop
// animations).
(content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)),
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// Structural CSS checks used by source files whose styles are not parsed by
// the static HTML engine.
// ---------------------------------------------------------------------------
const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i;
function insetStripeColorIsChromatic(rawColor) {
const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, '');
if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false;
const variable = color.match(/^var\(\s*(--[\w-]+)/i);
if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]);
if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false;
return !isNeutralAuthoredColor(color);
}
/**
* Blank out comment bodies while preserving every byte offset (and therefore
* every line number) so commented-out CSS is not scanned as live rules.
*/
function blankCssComments(css) {
return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '));
}
function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
const content = blankCssComments(rawContent);
const findings = [];
const ruleRe = /([^{};]+)\{([^{}]*)\}/g;
let match;
// Deriving each line with content.slice(0, offset).split('\n') re-scans the
// whole prefix per rule, which is O(n^2) on a large stylesheet. Rule matches
// arrive in source order, so carry a monotonic cursor instead: one pass total.
let scanOffset = 0;
let scanLine = 1;
const lineAtOffset = (offset) => {
while (scanOffset < offset) {
if (content[scanOffset] === '\n') scanLine++;
scanOffset++;
}
return scanLine;
};
while ((match = ruleRe.exec(content)) !== null) {
// The selector group is `[^{};]+`, which greedily absorbs the whitespace and
// newlines trailing the previous rule. Advance past that run before deriving
// the line, or every rule after the first reports the preceding line.
const selectorStart = match.index + (match[1].length - match[1].trimStart().length);
const selector = match[1].trim().replace(/\s+/g, ' ');
if (!selector) continue;
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
// Read the last of a repeated declaration, not the first: that is what the
// cascade paints. Taking the first both flagged stripes that a later
// `box-shadow: none` had cancelled and missed stripes that overrode an
// earlier value, and mis-skipped rules whose narrow width was overridden.
const width = lastMatch(match[2], /(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/gi);
if (width && Number(width[1]) <= 40) continue;
const declaration = lastMatch(match[2], /(?:^|;)\s*box-shadow\s*:\s*([^;]+)/gi);
if (!declaration || !/\binset\b/i.test(declaration[1])) continue;
// `!important` qualifies the declaration, not the shadow value, so strip it
// before the layers are read. Tokenizing split it into its own token, which
// made the color count wrong and silently stopped flagging stripes declared
// with it — a shape the previous regex handled.
const shadowValue = declaration[1].replace(/\s*!\s*important\s*$/i, '').trim();
for (const rawLayer of shadowValue.split(/,(?![^(]*\))/)) {
const layer = rawLayer.trim();
// Parse the layer by its grammar rather than by one spelling of it.
// A box-shadow layer is `inset? && <length>{2,4} && <color>?` in any
// order, so `inset 4px 0 red`, `4px 0 0 red inset`, and `red 4px 0 inset`
// all paint the same stripe. Matching a fixed token order missed three
// valid spellings in a row; enumerate the tokens instead. Tokenizing must
// respect parens: `rgb(0 0 0)` is one color token, and splitting it on
// whitespace would read its channels as lengths.
const tokens = tokenizeShadowLayer(layer);
if (!tokens.some((token) => /^inset$/i.test(token))) continue;
const rest = tokens.filter((token) => !/^inset$/i.test(token));
const lengths = rest.filter(isShadowLength);
const colors = rest.filter((token) => !isShadowLength(token));
// Only the two offsets are required; omitted blur/spread default to 0,
// which is exactly the stripe shape. More than one non-length token is a
// layer shape we do not claim to understand, so leave it alone.
if (lengths.length < 2 || lengths.length > 4 || colors.length !== 1) continue;
const values = lengths.map((token) => ({
n: Number(token.replace(/px$/i, '')),
hasPx: /px$/i.test(token),
}));
const x = values[0];
const y = values[1];
const blur = values[2] ? values[2].n : 0;
const spread = values[3] ? values[3].n : 0;
if ((x.n !== 0 && !x.hasPx) || (y.n !== 0 && !y.hasPx) || blur !== 0 || spread !== 0) continue;
const ax = Math.abs(x.n);
const ay = Math.abs(y.n);
if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue;
if (!insetStripeColorIsChromatic(colors[0])) continue;
const edge = ay === 0 ? (x.n > 0 ? 'left' : 'right') : (y.n > 0 ? 'top' : 'bottom');
const line = lineOffset + lineAtOffset(selectorStart);
findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line));
break;
}
}
return findings;
}
// ---------------------------------------------------------------------------
// Style block extraction (Astro/Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (ext !== '.vue' && ext !== '.svelte') return [];
if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
const blocks = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let m;
@@ -426,24 +595,24 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null,
}
/** Page-level analyzers that scan rendered text content (em-dash use,
* buzzword phrases, numbered section markers, aphoristic cadence).
* buzzword phrases, aphoristic cadence).
* These are detector-agnostic — they work on any HTML/text source
* and don't need a parsed DOM. Exported so detectHtml can call them
* for `.html` files (which otherwise skip the regex engine). */
const TEXT_CONTENT_ANALYZER_IDS = [
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
];
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!shouldRunPageAnalyzers(content, filePath)) return [];
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
// The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
// (single-font's removal on 2026-07-29 shifted every index down one).
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
const analyzer = REGEX_ANALYZERS[3 + i];
const analyzer = REGEX_ANALYZERS[2 + i];
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
findings.push(...profileFindings(profile, {
engine: 'regex',
@@ -468,8 +637,36 @@ function detectText(content, filePath, options = {}) {
profile,
phase: 'source',
}));
// Pseudo-element stripes (::before/::after absolute bars) carry the same
// side-tab silhouette without any border token, so the line matchers can't
// see them (issue #394). The shared scanner already runs on full HTML pages
// via checkHtmlPatterns; give standalone stylesheets, component style
// blocks, and CSS-in-JS templates the same coverage. Each hit carries the
// rule's source offset, so the finding gets a real line and line-scoped
// inline ignores keep working.
const pseudoStripeFindings = (text, lineOffset) =>
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
// Extract and scan <style> blocks from Vue/Svelte SFCs
if (cssLike.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
// Block-level CSS checks that need multiple declarations must run over the
// complete source, not line-by-line. This covers standalone stylesheets,
// component style blocks, inline styles, and CSS-in-JS templates.
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'source',
ruleId: 'codex-grid-background',
target: filePath,
}, () => scanCssTextForGridBackground(content).map(hit => {
const line = content.substring(0, hit.index).split('\n').length;
return finding('codex-grid-background', filePath, hit.snippet, line);
})));
// Extract and scan <style> blocks from Astro/Vue/Svelte components.
const styleBlocks = profile
? profileStep(profile, {
engine: 'regex',
@@ -484,6 +681,14 @@ function detectText(content, filePath, options = {}) {
profile,
phase: 'style-block',
}));
// block.startLine is the first line *after* the <style> tag, but block.content
// begins at the character right after that tag — so its own line 1 sits on the
// tag's line, whether or not a newline follows immediately. lineAtOffset is
// 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));
}
// Extract and scan CSS-in-JS template literals
@@ -501,6 +706,17 @@ function detectText(content, filePath, options = {}) {
profile,
phase: 'css-in-js',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
}
if (options?.designSystem) {
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
}
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
@@ -517,12 +733,10 @@ function detectText(content, filePath, options = {}) {
// Page-level analyzers only run on full pages
if (shouldRunPageAnalyzers(content, filePath)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
'dark-glow',
];
@@ -537,7 +751,9 @@ function detectText(content, filePath, options = {}) {
}
}
return filterByProviders(deduped, options?.providers);
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
return options?.inlineIgnores === false ? deduped : applyInlineIgnores(deduped, content);
}
export {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
import { CSS_NAMED_COLORS, collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -245,9 +245,14 @@ const STATIC_DEFAULT_STYLE = {
outlineColor: 'rgb(0, 0, 0)',
outlineStyle: 'none',
boxShadow: 'none',
// NOT in STATIC_INHERITED_PROPS even though text-shadow inherits in real
// CSS: the glow check only needs to fire once, on the element that
// declares the shadow, not on every descendant.
textShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
@@ -272,6 +277,7 @@ const STATIC_DEFAULT_STYLE = {
marginBottom: '0px',
marginLeft: '0px',
position: 'static',
visibility: 'visible',
top: 'auto',
right: 'auto',
bottom: 'auto',
@@ -301,6 +307,7 @@ const STATIC_PROP_MAP = {
'outline-color': 'outlineColor',
'outline-style': 'outlineStyle',
'box-shadow': 'boxShadow',
'text-shadow': 'textShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
@@ -326,6 +333,7 @@ const STATIC_PROP_MAP = {
'margin-bottom': 'marginBottom',
'margin-left': 'marginLeft',
'position': 'position',
'visibility': 'visibility',
'top': 'top',
'right': 'right',
'bottom': 'bottom',
@@ -337,18 +345,29 @@ const STATIC_PROP_MAP = {
'overflow-y': 'overflowY',
};
// parseStaticColor tries parseAnyColor first, which already resolves every
// name in the shared CSS_NAMED_COLORS table. This fallback only carries the
// keywords parseAnyColor deliberately returns null for: the cascade needs
// `transparent` to read as an actual zero-alpha color.
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
// Named-color alternation for plucking a color token out of shorthand values
// (issue #359: a hardcoded 9-name list here silently dropped `purple`,
// `crimson`, `teal`, ... from border shorthands, so the side defaulted to
// neutral black and side-tab never fired on .html files). Derived from the
// same table parseAnyColor resolves against, so extraction and parsing can't
// drift apart. Longest-first so names containing other names as substrings
// (rebeccapurple) are matched whole.
const NAMED_COLOR_TOKENS = [...Object.keys(CSS_NAMED_COLORS), ...Object.keys(STATIC_NAMED_COLORS)]
.sort((a, b) => b.length - a.length)
.join('|');
const STATIC_COLOR_TOKEN_RE = new RegExp(
`(?:rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|lch\\([^)]+\\)|lab\\([^)]+\\)|hsla?\\([^)]+\\)|hwb\\([^)]+\\)|#[0-9a-f]{3,8}\\b|\\b(?:${NAMED_COLOR_TOKENS})\\b)`,
'i'
);
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
@@ -418,7 +437,23 @@ function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
// color-mix(...) needs balanced-paren capture (its arguments regularly
// contain nested var()/oklch() calls AND the keyword `transparent`, which
// the flat regex below would otherwise pluck out of the middle of the
// expression and report as the whole color).
const mixStart = raw.search(/color-mix\(/i);
if (mixStart !== -1) {
let depth = 0;
for (let i = raw.indexOf('(', mixStart); i < raw.length; i++) {
if (raw[i] === '(') depth++;
else if (raw[i] === ')') {
depth--;
if (depth === 0) return raw.slice(mixStart, i + 1);
}
}
return '';
}
const colorLike = raw.match(STATIC_COLOR_TOKEN_RE);
if (!colorLike) return '';
return colorLike[0];
}
@@ -530,6 +565,15 @@ function expandStaticDeclaration(prop, value) {
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
// The `background` shorthand resets every longhand it does not set.
// Without this, `pre code { background: none }` leaves an earlier
// `background: var(--surface)` color standing and the contrast checks
// measure text against a surface the browser never paints. var() values
// stay untouched: they may resolve to a color later in the pipeline.
if (!color && !hasImage && !/var\(/i.test(v)) {
out.push(['backgroundColor', 'rgba(0, 0, 0, 0)']);
out.push(['backgroundImage', 'none']);
}
return out;
}
if (p === 'border') {
@@ -700,7 +744,20 @@ function collectStaticCssRules(cssText, csstree) {
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
if (!selector) continue;
// :hover rules can't be matched statically as-is (no interaction
// state), but they carry real cascade weight while hovered. Tag
// them and record a state-stripped selector so the hover pass can
// find their targets; specificity stays computed from the ORIGINAL
// selector (per CSS, :hover counts as a class).
const isHover = /:hover\b/i.test(selector);
let matchSelector = null;
if (isHover) {
matchSelector = selector.replace(/:hover\b/gi, '').trim();
if (!matchSelector || /[>+~]\s*$/.test(matchSelector)) matchSelector = null;
else matchSelector = matchSelector.replace(/(^|[\s>+~])(?=$|[\s>+~])/g, '$1*');
}
rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++, isHover, matchSelector });
}
return;
}
@@ -803,6 +860,13 @@ class StaticDocument {
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
this._hoverStyleMap = new WeakMap();
this._accentDashPseudo = new WeakSet();
// Elements whose ::before/::after paints a full-cover opaque surface
// (position absolute/fixed + inset 0 + solid background). The pseudo is
// the element's visible background for contrast purposes even though it
// never joins the element cascade.
this._pseudoSurface = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
@@ -839,6 +903,24 @@ class StaticDocument {
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
setHoverStyle(node, style) {
this._hoverStyleMap.set(node, style);
}
getHoverStyle(el) {
return this._hoverStyleMap.get(el.node) || null;
}
setAccentDashPseudo(node) {
this._accentDashPseudo.add(node);
}
hasAccentDashPseudo(el) {
return this._accentDashPseudo.has(el.node);
}
setPseudoSurface(node, color) {
this._pseudoSurface.set(node, color);
}
getPseudoSurface(el) {
return this._pseudoSurface.get(el.node) || null;
}
}
function makeStaticStyle(values = {}) {
@@ -854,6 +936,9 @@ function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
getPseudoSurface: (el) => staticDoc.getPseudoSurface(el),
};
}
@@ -867,7 +952,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
// literal path with the query in it; a versioned link otherwise made the
// whole stylesheet invisible to every element-level check.
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
try {
const css = profileStep(profile, {
engine: 'static-html',
@@ -884,6 +972,13 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
// Declarations from :hover rules, matched via their state-stripped
// selectors. Merged per-property against the resting cascade in
// computeNode — a hover declaration only takes effect if it would win
// the cascade while the element is hovered (all resting rules still
// apply in that state).
const hoverSpecified = new Map();
const rootCustomProps = collectCssCustomProps(cssText);
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
@@ -899,9 +994,65 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
target: filePath,
}, () => {
for (const rule of rules) {
// ::before/::after rules can't join the element cascade (pseudo
// elements aren't DOM nodes), but one shape matters to the eyebrow
// check: the short chromatic "kicker dash" (content box 8-80px wide,
// 1-6px tall, accent-colored fill). Mark the base-selector matches
// so checkElementHeroEyebrow can see the dash.
if (!rule.isHover) {
const pm = rule.selector.match(/^(.+?)\s*::?(?:before|after)$/i);
if (pm) {
const decls = new Map();
for (const d of rule.declarations) decls.set(d.prop.toLowerCase(), d.value);
const w = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', rootCustomProps));
const h = cssLengthToPx(resolveVarRefs(decls.get('height') || decls.get('block-size') || '', rootCustomProps));
if (w != null && h != null && w >= 8 && w <= 80 && h >= 1 && h <= 6) {
const bgRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
const token = bgRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
const c = parseAnyColor(token ? token[0] : bgRaw);
if (c && (c.a ?? 1) >= 0.1 && Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= 30) {
try {
for (const node of modules.selectAll(pm[1], root.children || [])) {
staticDoc.setAccentDashPseudo(node);
}
} catch { /* unsupported base selector */ }
}
}
// Full-cover surface pseudo: the CTA construction where the
// element itself stays transparent and a ::before/::after with
// position absolute/fixed + inset 0 (or all four sides 0, or
// 100% width and height) plus an opaque background paints the
// visible surface. Mark base-selector matches so the contrast
// checks measure text against the surface the browser renders.
const pseudoPos = String(decls.get('position') || '').toLowerCase();
if (pseudoPos === 'absolute' || pseudoPos === 'fixed') {
const zeroLen = v => v != null && /^0(?:px)?$/.test(String(v).trim());
const insetRaw = String(decls.get('inset') || '').trim();
const coversBox = (insetRaw !== '' && insetRaw.split(/\s+/).every(t => /^0(?:px)?$/.test(t)))
|| ['top', 'right', 'bottom', 'left'].every(side => zeroLen(decls.get(side)))
|| (String(decls.get('width') || '').trim() === '100%'
&& String(decls.get('height') || '').trim() === '100%');
if (coversBox && decls.has('content')) {
const surfRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
const surfToken = surfRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
const surf = parseAnyColor(surfToken ? surfToken[0] : surfRaw);
if (surf && (surf.a ?? 1) >= 0.9 && !/gradient/i.test(surfRaw)) {
try {
for (const node of modules.selectAll(pm[1], root.children || [])) {
staticDoc.setPseudoSurface(node, surf);
}
} catch { /* unsupported base selector */ }
}
}
}
continue;
}
}
const matchSelector = rule.isHover ? rule.matchSelector : rule.selector;
if (!matchSelector) continue;
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
matched = modules.selectAll(matchSelector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
@@ -910,13 +1061,13 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
detail: matchSelector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
applyStaticDeclaration(rule.isHover ? hoverSpecified : specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
@@ -959,6 +1110,28 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
// Hover pass: limited to the two properties the hover-contrast check
// consumes. A hover declaration wins only if it beats the resting
// winner for that property under normal cascade rules (specificity /
// order / importance) — exactly what a browser computes while the
// element is hovered.
const hoverMap = hoverSpecified.get(node);
if (hoverMap) {
let hoverValues = null;
for (const prop of ['color', 'backgroundColor']) {
const hoverDecl = hoverMap.get(prop);
if (!hoverDecl) continue;
const restingDecl = specifiedMap.get(prop);
if (!compareStaticPriority(restingDecl, hoverDecl)) continue;
const next = normalizeStaticCssValue(prop, hoverDecl.value, customProps, parentStyle, values);
if (next === values[prop]) continue;
if (!hoverValues) hoverValues = { ...values };
hoverValues[prop] = next;
}
if (hoverValues) staticDoc.setHoverStyle(node, makeStaticStyle(hoverValues));
}
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
@@ -2,7 +2,13 @@ import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import {
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
@@ -12,20 +18,23 @@ import {
checkElementGlow,
checkElementGptBorderShadow,
checkElementHeroEyebrow,
checkElementHoverContrast,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementOversizedH1,
checkElementQuality,
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
checkRepeatedContainerTextFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
import {
StaticDocument,
@@ -51,9 +60,6 @@ function checkStaticPageTypography(document, window) {
for (const font of overusedFound) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
@@ -84,8 +90,9 @@ function checkElementBrokenImage(el) {
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window), el) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ id: 'hover-color-rules', selector: '*', run: (el, tag, style, window) => checkElementHoverContrast(el, style, tag, window) },
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
@@ -96,6 +103,7 @@ const STATIC_ELEMENT_RULES = [
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
@@ -168,6 +176,22 @@ async function detectHtml(filePath, options = {}) {
}
}
if (options?.designSystem) {
const sourceDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
const staticDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'page',
ruleId: 'design-system',
target: filePath,
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
@@ -175,7 +199,13 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-container-text', () => checkRepeatedContainerTextFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
@@ -187,10 +217,33 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
// Scoped corpora for the pattern checks (see buildHtmlPatternCorpora in
// rules/checks.mjs): CSS-property regexes must not fire on prose ABOUT
// css — `<code>background-clip: text</code>` in a changelog is
// documentation, not styling. cssText already carries the <style>
// blocks and any linked local stylesheets; style/class attributes come
// from the parsed document, so escaped code samples never contribute.
const styleAttrParts = [];
const classAttrParts = [];
for (const el of document.querySelectorAll('*')) {
const styleAttr = el.getAttribute('style');
if (styleAttr) styleAttrParts.push(`style="${styleAttr}"`);
const classAttr = el.getAttribute('class');
if (classAttr) classAttrParts.push(classAttr);
}
const patternCorpora = {
styleText: [cssText, ...styleAttrParts].join('\n'),
classText: classAttrParts.join('\n'),
};
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
const item = finding(f.id, filePath, f.snippet);
// Position-aware severity promotion: checks may attach a per-finding
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -202,7 +255,10 @@ async function detectHtml(filePath, options = {}) {
}
}
return filterByProviders(findings, options.providers);
// Static-HTML findings carry no line number, so only whole-file
// `impeccable-disable` directives apply here — exactly the standalone-document
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
return options?.inlineIgnores === false ? findings : applyInlineIgnores(findings, html);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };