Bug fixes
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
|
||||
|
||||
// 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 [];
|
||||
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
|
||||
? options.visualContrastMaxCandidates
|
||||
: 12;
|
||||
const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
|
||||
const existingLowContrastSelectors = new Set(
|
||||
serializedGroups
|
||||
.filter(group => group.findings?.some(f => f.type === 'low-contrast'))
|
||||
.map(group => group.selector)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let browserAnalyses = [];
|
||||
const findings = [];
|
||||
if (options?.visualContrastBrowser !== false) {
|
||||
const browserFindings = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'visual-contrast',
|
||||
ruleId: 'browser-fallback',
|
||||
target,
|
||||
}, async () => {
|
||||
browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
|
||||
if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
|
||||
return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
|
||||
}, { maxCandidates, scrollOffscreen });
|
||||
return browserAnalyses
|
||||
.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
|
||||
.map(result => result.finding);
|
||||
});
|
||||
findings.push(...browserFindings);
|
||||
}
|
||||
|
||||
let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
|
||||
if (candidates.length === 0) {
|
||||
candidates = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'visual-contrast',
|
||||
ruleId: 'collect-candidates',
|
||||
target,
|
||||
}, () => page.evaluate(({ maxCandidates }) => {
|
||||
if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
|
||||
return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
|
||||
}, { maxCandidates }));
|
||||
}
|
||||
|
||||
const viewport = options?.viewport || { width: 1280, height: 800 };
|
||||
const browserResolvedSelectors = new Set(
|
||||
browserAnalyses
|
||||
.filter(result => result.status === 'fail' || result.status === 'pass')
|
||||
.map(result => result.selector)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const filtered = candidates.filter(candidate =>
|
||||
!existingLowContrastSelectors.has(candidate.selector) &&
|
||||
!browserResolvedSelectors.has(candidate.selector)
|
||||
);
|
||||
if (options?.visualContrastPixel === false) return findings;
|
||||
for (const candidate of filtered) {
|
||||
const result = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'visual-contrast',
|
||||
ruleId: 'pixel-diff',
|
||||
target,
|
||||
}, async () => {
|
||||
const finding = await captureVisualContrastCandidate(page, candidate, viewport);
|
||||
return finding ? [finding] : [];
|
||||
});
|
||||
findings.push(...result);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Puppeteer detection (for URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function detectUrl(url, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const waitUntil = options?.waitUntil || 'networkidle0';
|
||||
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
|
||||
const viewport = options?.viewport || { width: 1280, height: 800 };
|
||||
const externalBrowser = options?.browser || null;
|
||||
let puppeteer;
|
||||
if (!externalBrowser) {
|
||||
try {
|
||||
puppeteer = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'setup',
|
||||
ruleId: 'import-puppeteer',
|
||||
target: url,
|
||||
}, () => import('puppeteer'));
|
||||
} catch {
|
||||
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
|
||||
}
|
||||
}
|
||||
|
||||
// Read the browser detection script — reuse it instead of reimplementing
|
||||
const browserScriptPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'detect-antipatterns-browser.js'
|
||||
);
|
||||
let browserScript;
|
||||
try {
|
||||
browserScript = profileStep(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'setup',
|
||||
ruleId: 'read-browser-script',
|
||||
target: url,
|
||||
}, () => fs.readFileSync(browserScriptPath, 'utf-8'));
|
||||
} catch {
|
||||
throw new Error(`Browser script not found at ${browserScriptPath}`);
|
||||
}
|
||||
|
||||
// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
|
||||
// Chrome can't initialize its sandbox there. Disable the sandbox only when
|
||||
// running in CI; local users keep the default hardened launch.
|
||||
const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
|
||||
const browser = externalBrowser || await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'launch-browser',
|
||||
target: url,
|
||||
}, () => puppeteer.default.launch({ 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, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'set-viewport',
|
||||
target: url,
|
||||
}, () => page.setViewport(viewport));
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: `goto:${waitUntil}`,
|
||||
target: url,
|
||||
}, () => page.goto(url, { waitUntil, timeout: 30000 }));
|
||||
if (settleMs > 0) {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'settle',
|
||||
target: url,
|
||||
}, () => new Promise(resolve => setTimeout(resolve, settleMs)));
|
||||
}
|
||||
|
||||
// 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((designSystem) => {
|
||||
window.__IMPECCABLE_CONFIG__ = {
|
||||
...(window.__IMPECCABLE_CONFIG__ || {}),
|
||||
autoScan: false,
|
||||
...(designSystem ? { designSystem } : {}),
|
||||
};
|
||||
}, browserDesignSystem));
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'inject-browser-script',
|
||||
target: url,
|
||||
}, () => page.evaluate(browserScript));
|
||||
let serializedGroups = [];
|
||||
results = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'browser-scan',
|
||||
target: url,
|
||||
}, async () => {
|
||||
serializedGroups = await page.evaluate(() => {
|
||||
if (!window.impeccableDetect) return [];
|
||||
return window.impeccableDetect({ decorate: false, serialize: true });
|
||||
});
|
||||
return serializedGroups.flatMap(({ findings }) =>
|
||||
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 {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'close-page',
|
||||
target: url,
|
||||
}, () => page.close().catch(() => {}));
|
||||
if (!externalBrowser) {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'close-browser',
|
||||
target: url,
|
||||
}, () => browser.close());
|
||||
}
|
||||
}
|
||||
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 = {}) {
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = await import('puppeteer');
|
||||
} catch {
|
||||
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({
|
||||
headless: options.headless ?? true,
|
||||
args: launchArgs,
|
||||
});
|
||||
const ownsBrowser = !options.browser;
|
||||
const defaults = {
|
||||
waitUntil: options.waitUntil || 'load',
|
||||
settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
|
||||
viewport: options.viewport || { width: 1280, height: 800 },
|
||||
};
|
||||
return {
|
||||
browser,
|
||||
async detectUrl(url, scanOptions = {}) {
|
||||
return detectUrl(url, {
|
||||
...defaults,
|
||||
...scanOptions,
|
||||
browser,
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
if (ownsBrowser) await browser.close().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
|
||||
@@ -0,0 +1,769 @@
|
||||
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, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.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 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) {
|
||||
return html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
|
||||
|
||||
function extFromFilePath(filePath) {
|
||||
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function shouldRunPageAnalyzers(content, filePath) {
|
||||
if (!isFullPage(content)) return false;
|
||||
const ext = extFromFilePath(filePath);
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 >= 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 >= 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,
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
|
||||
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
|
||||
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
|
||||
test: (m) => +m[1] >= 3,
|
||||
fmt: (m) => m[0] },
|
||||
// --- Border accent on rounded ---
|
||||
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
|
||||
test: (m, line) => hasRounded(line) && +m[1] >= 1,
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
|
||||
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
|
||||
fmt: (m) => m[0] },
|
||||
// --- Overused font ---
|
||||
{ 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?\?[^"'\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),
|
||||
fmt: () => 'background-clip: text + gradient' },
|
||||
// --- Gradient text (Tailwind) ---
|
||||
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
||||
test: (m, line) => /\bbg-gradient-to-/i.test(line),
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
fmt: (m) => `${m[0]} on heading` },
|
||||
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
|
||||
fmt: (m) => `${m[0]} gradient` },
|
||||
// --- Bounce/elastic easing ---
|
||||
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
|
||||
test: () => true,
|
||||
fmt: () => 'animate-bounce (Tailwind)' },
|
||||
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => {
|
||||
const token = m[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
return `animation: ${token || m[1].trim()}`;
|
||||
} },
|
||||
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
|
||||
test: (m) => {
|
||||
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
|
||||
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
|
||||
},
|
||||
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
||||
// --- Layout property transition ---
|
||||
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
} },
|
||||
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
||||
test: (m) => {
|
||||
const val = m[1].toLowerCase();
|
||||
if (/\ball\b/.test(val)) return false;
|
||||
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
||||
},
|
||||
fmt: (m) => {
|
||||
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
||||
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
||||
} },
|
||||
// --- Broken image: src="" or src="#" or src=" " ---
|
||||
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0].slice(0, 100) },
|
||||
// --- Broken image: <img> with no src attribute at all ---
|
||||
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
|
||||
test: (m) => !/\bsrc\s*=/i.test(m[0]),
|
||||
fmt: (m) => m[0].slice(0, 100) },
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(content)) 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();
|
||||
const REM = 16;
|
||||
let m;
|
||||
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
|
||||
while ((m = sizeRe.exec(content)) !== null) {
|
||||
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
|
||||
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
|
||||
}
|
||||
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
|
||||
while ((m = clampRe.exec(content)) !== null) {
|
||||
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
|
||||
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
|
||||
}
|
||||
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
|
||||
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
|
||||
if (sizes.size < 3) return [];
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio >= 2.0) return [];
|
||||
const lines = content.split('\n');
|
||||
let line = 1;
|
||||
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
|
||||
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
|
||||
},
|
||||
// Monotonous spacing (regex)
|
||||
(content, filePath) => {
|
||||
const vals = [];
|
||||
let m;
|
||||
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
||||
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
|
||||
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
||||
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
|
||||
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
||||
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
|
||||
const rounded = vals.map(v => Math.round(v / 4) * 4);
|
||||
if (rounded.length < 10) return [];
|
||||
const counts = {};
|
||||
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
|
||||
const maxCount = Math.max(...Object.values(counts));
|
||||
const pct = maxCount / rounded.length;
|
||||
const unique = [...new Set(rounded)].filter(v => v > 0);
|
||||
if (pct <= 0.6 || unique.length > 3) return [];
|
||||
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 (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 `—`, `—`, or `—` 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)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
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
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const lower = text.toLowerCase();
|
||||
const BUZZWORDS = [
|
||||
'streamline your', 'empower your', 'supercharge your',
|
||||
'unleash your', 'unleash the power', 'leverage the power',
|
||||
'built for the modern', 'trusted by leading', 'trusted by the world',
|
||||
'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
|
||||
'next-generation', 'cutting-edge', 'transform your business',
|
||||
'revolutionize', 'game-changer', 'game changing',
|
||||
'mission-critical', 'best of breed', 'future-proof', 'future proof',
|
||||
'seamless experience', 'seamlessly integrate',
|
||||
'drive engagement', 'drive growth', 'drive results',
|
||||
'harness the power',
|
||||
];
|
||||
let count = 0;
|
||||
let firstSample = '';
|
||||
for (const phrase of BUZZWORDS) {
|
||||
let from = 0;
|
||||
while (true) {
|
||||
const idx = lower.indexOf(phrase, from);
|
||||
if (idx === -1) break;
|
||||
count++;
|
||||
if (!firstSample) {
|
||||
firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
|
||||
}
|
||||
from = idx + phrase.length;
|
||||
}
|
||||
}
|
||||
if (count === 0) return [];
|
||||
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
|
||||
},
|
||||
// Aphoristic cadence: manufactured-contrast + short-rebuttal
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
|
||||
const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
|
||||
let count = 0;
|
||||
let firstSample = '';
|
||||
let m;
|
||||
NOT_A_RE.lastIndex = 0;
|
||||
while ((m = NOT_A_RE.exec(text)) !== null) {
|
||||
count++;
|
||||
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
|
||||
}
|
||||
SHORT_REBUTTAL_RE.lastIndex = 0;
|
||||
while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
|
||||
count++;
|
||||
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
|
||||
}
|
||||
if (count < 3) return [];
|
||||
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
|
||||
},
|
||||
// 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) => {
|
||||
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)),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
|
||||
const blocks = [];
|
||||
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
const startLine = before.split('\n').length + 1;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS-in-JS extraction (styled-components, emotion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||
|
||||
function extractCSSinJS(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||
const blocks = [];
|
||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
const startLine = before.split('\n').length;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
||||
const { profile, phase = 'regex-matchers' } = options || {};
|
||||
const findings = [];
|
||||
if (!profile) {
|
||||
for (const matcher of REGEX_MATCHERS) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
matcher.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = matcher.regex.exec(line)) !== null) {
|
||||
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
||||
const context = blockContext
|
||||
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
||||
: line;
|
||||
if (matcher.test(m, context)) {
|
||||
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
for (const matcher of REGEX_MATCHERS) {
|
||||
const matcherFindings = profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase,
|
||||
ruleId: matcher.id,
|
||||
target: filePath,
|
||||
}, () => {
|
||||
const matches = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
matcher.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = matcher.regex.exec(line)) !== null) {
|
||||
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
||||
const context = blockContext
|
||||
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
||||
: line;
|
||||
if (matcher.test(m, context)) {
|
||||
matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
});
|
||||
findings.push(...matcherFindings);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Page-level analyzers that scan rendered text content (em-dash use,
|
||||
* 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',
|
||||
'aphoristic-cadence',
|
||||
];
|
||||
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 3 text-content analyzers are at indices 3-5 in REGEX_ANALYZERS.
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[3 + i];
|
||||
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'text-content',
|
||||
ruleId,
|
||||
target: filePath,
|
||||
}, () => analyzer(content, filePath)));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function detectText(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = extFromFilePath(filePath);
|
||||
|
||||
// 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, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
if (cssLike.has(ext)) findings.push(...scanInsetStripeCss(content, filePath));
|
||||
|
||||
// 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',
|
||||
phase: 'extract',
|
||||
ruleId: 'style-blocks',
|
||||
target: filePath,
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
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));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
const cssJsBlocks = profile
|
||||
? profileStep(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'extract',
|
||||
ruleId: 'css-in-js',
|
||||
target: filePath,
|
||||
}, () => extractCSSinJS(content, ext))
|
||||
: extractCSSinJS(content, ext);
|
||||
for (const block of cssJsBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, 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)
|
||||
const deduped = [];
|
||||
for (const f of findings) {
|
||||
const isDupe = deduped.some(d =>
|
||||
d.antipattern === f.antipattern &&
|
||||
d.snippet === f.snippet &&
|
||||
Math.abs(d.line - f.line) <= 2
|
||||
);
|
||||
if (!isDupe) deduped.push(f);
|
||||
}
|
||||
|
||||
// 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',
|
||||
'aphoristic-cadence',
|
||||
'dark-glow',
|
||||
];
|
||||
for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[i];
|
||||
deduped.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'page-analyzer',
|
||||
ruleId: analyzerIds[i] || `analyzer-${i + 1}`,
|
||||
target: filePath,
|
||||
}, () => analyzer(content, filePath)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
REGEX_MATCHERS,
|
||||
REGEX_ANALYZERS,
|
||||
TEXT_CONTENT_ANALYZER_IDS,
|
||||
extractStyleBlocks,
|
||||
extractCSSinJS,
|
||||
runRegexMatchers,
|
||||
runTextContentAnalyzers,
|
||||
detectText,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
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 {
|
||||
checkElementBorders,
|
||||
checkElementClippedOverflow,
|
||||
checkElementColors,
|
||||
checkElementGlow,
|
||||
checkElementGptBorderShadow,
|
||||
checkElementHeroEyebrow,
|
||||
checkElementHoverContrast,
|
||||
checkElementIconTile,
|
||||
checkElementItalicSerif,
|
||||
checkElementMotion,
|
||||
checkElementOversizedH1,
|
||||
checkElementQuality,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
checkRepeatedSectionKickersFromDoc,
|
||||
resolveBackground,
|
||||
resolveBorderRadiusPx,
|
||||
} from '../../rules/checks.mjs';
|
||||
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
|
||||
import {
|
||||
StaticDocument,
|
||||
buildStaticStyleMap,
|
||||
buildStaticWindow,
|
||||
collectStaticCssText,
|
||||
} from './css-cascade.mjs';
|
||||
|
||||
function checkStaticPageTypography(document, window) {
|
||||
const findings = [];
|
||||
const fonts = new Set();
|
||||
const overusedFound = new Set();
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const ff = window.getComputedStyle(el).fontFamily || '';
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
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);
|
||||
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkElementBrokenImage(el) {
|
||||
const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src;
|
||||
// Missing src attribute entirely
|
||||
if (src === undefined || src === null) {
|
||||
return [{ id: 'broken-image', snippet: '<img> with no src attribute' }];
|
||||
}
|
||||
const trimmed = String(src).trim();
|
||||
// Empty or placeholder-only src values
|
||||
if (trimmed === '' || trimmed === '#') {
|
||||
return [{ id: 'broken-image', snippet: `<img src="${src}">` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
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), 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) },
|
||||
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
|
||||
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
|
||||
{ id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) },
|
||||
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
|
||||
{ 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) },
|
||||
];
|
||||
|
||||
async function detectHtml(filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const html = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'setup',
|
||||
ruleId: 'read-html',
|
||||
target: filePath,
|
||||
}, () => fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
let modules;
|
||||
try {
|
||||
modules = await profileStepAsync(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'setup',
|
||||
ruleId: 'import-static-parser',
|
||||
target: filePath,
|
||||
}, async () => {
|
||||
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
|
||||
import('htmlparser2'),
|
||||
import('css-select'),
|
||||
import('css-tree'),
|
||||
import('domutils'),
|
||||
]);
|
||||
return {
|
||||
parseDocument: htmlparser2.parseDocument,
|
||||
selectAll: cssSelect.selectAll,
|
||||
selectOne: cssSelect.selectOne,
|
||||
is: cssSelect.is,
|
||||
csstree,
|
||||
domutils,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return detectText(html, filePath, options);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
const root = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'parse-html',
|
||||
ruleId: 'parse-document',
|
||||
target: filePath,
|
||||
}, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
|
||||
|
||||
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
|
||||
const document = new StaticDocument(root, modules);
|
||||
buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
|
||||
const window = buildStaticWindow(document);
|
||||
|
||||
const customPropMap = null;
|
||||
|
||||
const findings = [];
|
||||
const runElementCheck = (ruleId, callback) => profile
|
||||
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
|
||||
: callback();
|
||||
|
||||
const visitedByRule = new Map();
|
||||
for (const rule of STATIC_ELEMENT_RULES) {
|
||||
const elements = document.querySelectorAll(rule.selector);
|
||||
visitedByRule.set(rule.id, elements.length);
|
||||
for (const el of elements) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = window.getComputedStyle(el);
|
||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
: callback();
|
||||
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))) {
|
||||
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))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
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 =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
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
|
||||
// engine. Call them from here so .html files get the same coverage
|
||||
// as .css/.tsx files. These are scoped to text content only and
|
||||
// don't overlap with static-html's element/page rules.
|
||||
for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) {
|
||||
findings.push(finding(f.antipattern, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
@@ -0,0 +1,189 @@
|
||||
function sanitizeScreenshotClip(clip, viewport) {
|
||||
if (!clip) return null;
|
||||
const x = Math.max(0, Math.floor(clip.x || 0));
|
||||
const y = Math.max(0, Math.floor(clip.y || 0));
|
||||
const width = Math.min(
|
||||
Math.max(1, Math.ceil(clip.width || 0)),
|
||||
Math.max(1, viewport?.width || 1600),
|
||||
);
|
||||
const height = Math.min(
|
||||
Math.max(1, Math.ceil(clip.height || 0)),
|
||||
320,
|
||||
);
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
|
||||
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
|
||||
const loadImage = (base64) => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
|
||||
img.src = `data:image/png;base64,${base64}`;
|
||||
});
|
||||
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
|
||||
const width = Math.min(before.width, after.width);
|
||||
const height = Math.min(before.height, after.height);
|
||||
if (width < 1 || height < 1) return null;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.drawImage(before, 0, 0, width, height);
|
||||
const beforePixels = ctx.getImageData(0, 0, width, height).data;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(after, 0, 0, width, height);
|
||||
const afterPixels = ctx.getImageData(0, 0, width, height).data;
|
||||
|
||||
const luminance = ({ r, g, b }) => {
|
||||
const convert = c => {
|
||||
const v = c / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
|
||||
};
|
||||
const ratio = (a, b) => {
|
||||
const l1 = luminance(a);
|
||||
const l2 = luminance(b);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
};
|
||||
|
||||
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
|
||||
? {
|
||||
r: candidate.textColor.r,
|
||||
g: candidate.textColor.g,
|
||||
b: candidate.textColor.b,
|
||||
}
|
||||
: null;
|
||||
const ratios = [];
|
||||
let glyphPixels = 0;
|
||||
let strongestDelta = 0;
|
||||
for (let i = 0; i < beforePixels.length; i += 4) {
|
||||
const delta = Math.abs(beforePixels[i] - afterPixels[i])
|
||||
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
|
||||
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
|
||||
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
|
||||
strongestDelta = Math.max(strongestDelta, delta);
|
||||
if (delta < 10) continue;
|
||||
glyphPixels++;
|
||||
const fg = cssTextColor || {
|
||||
r: beforePixels[i],
|
||||
g: beforePixels[i + 1],
|
||||
b: beforePixels[i + 2],
|
||||
};
|
||||
const bg = {
|
||||
r: afterPixels[i],
|
||||
g: afterPixels[i + 1],
|
||||
b: afterPixels[i + 2],
|
||||
};
|
||||
ratios.push(ratio(fg, bg));
|
||||
}
|
||||
|
||||
if (ratios.length < 8) {
|
||||
return {
|
||||
glyphPixels,
|
||||
strongestDelta,
|
||||
worstRatio: null,
|
||||
p10Ratio: null,
|
||||
medianRatio: null,
|
||||
};
|
||||
}
|
||||
|
||||
ratios.sort((a, b) => a - b);
|
||||
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
|
||||
return {
|
||||
glyphPixels,
|
||||
strongestDelta,
|
||||
worstRatio: ratios[0],
|
||||
p10Ratio: pick(10),
|
||||
medianRatio: pick(50),
|
||||
};
|
||||
}, { beforeBase64, afterBase64, candidate });
|
||||
}
|
||||
|
||||
async function captureVisualContrastCandidate(page, candidate, viewport) {
|
||||
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
|
||||
if (!clip) return null;
|
||||
|
||||
const beforeBase64 = await page.screenshot({
|
||||
encoding: 'base64',
|
||||
clip,
|
||||
captureBeyondViewport: true,
|
||||
});
|
||||
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
|
||||
let el;
|
||||
try {
|
||||
el = document.querySelector(selector);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!el) return false;
|
||||
let style = document.getElementById('impeccable-visual-contrast-hide-style');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'impeccable-visual-contrast-hide-style';
|
||||
style.textContent = [
|
||||
'[data-impeccable-visual-contrast-target] {',
|
||||
' color: transparent !important;',
|
||||
' -webkit-text-fill-color: transparent !important;',
|
||||
' text-shadow: none !important;',
|
||||
'}',
|
||||
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
|
||||
' background-image: none !important;',
|
||||
'}',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
el.setAttribute('data-impeccable-visual-contrast-target', token);
|
||||
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
|
||||
return true;
|
||||
}, {
|
||||
selector: candidate.selector,
|
||||
token,
|
||||
backgroundClipText: candidate.backgroundClipText,
|
||||
});
|
||||
if (!applied) return null;
|
||||
|
||||
let afterBase64;
|
||||
try {
|
||||
afterBase64 = await page.screenshot({
|
||||
encoding: 'base64',
|
||||
clip,
|
||||
captureBeyondViewport: true,
|
||||
});
|
||||
} finally {
|
||||
await page.evaluate(({ selector }) => {
|
||||
try {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
el.removeAttribute('data-impeccable-visual-contrast-target');
|
||||
el.removeAttribute('data-impeccable-bgclip-text');
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid or stale selectors during cleanup.
|
||||
}
|
||||
}, { selector: candidate.selector }).catch(() => {});
|
||||
}
|
||||
|
||||
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
|
||||
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
|
||||
const measuredRatio = metrics.p10Ratio;
|
||||
if (measuredRatio >= candidate.threshold) return null;
|
||||
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
|
||||
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
|
||||
return {
|
||||
id: 'low-contrast',
|
||||
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
sanitizeScreenshotClip,
|
||||
compareScreenshotContrast,
|
||||
captureVisualContrastCandidate,
|
||||
};
|
||||
Reference in New Issue
Block a user