chore(skills): configuration des compétences Impeccable pour les agents
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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';
|
||||
|
||||
// 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 [];
|
||||
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,
|
||||
}, () => 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, {
|
||||
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 launchBrowser(puppeteer, {
|
||||
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, launchBrowser };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
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,
|
||||
checkElementRadialSpotlight,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
scopedIgnoreActive,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
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}` });
|
||||
}
|
||||
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) },
|
||||
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
|
||||
];
|
||||
|
||||
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,
|
||||
compile: cssSelect.compile,
|
||||
csstree,
|
||||
domutils,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (!globalThis.__impeccableStaticHtmlWarned) {
|
||||
globalThis.__impeccableStaticHtmlWarned = true;
|
||||
|
||||
process.stderr.write(
|
||||
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
|
||||
'(htmlparser2, css-select, css-tree, domutils).\n' +
|
||||
'Falling back to regex matching. Custom properties, selector matching and computed ' +
|
||||
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
|
||||
);
|
||||
}
|
||||
|
||||
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))) {
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its subtree, same as the browser walk.
|
||||
if (scopedIgnoreActive(el, f.id)) continue;
|
||||
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('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))) {
|
||||
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));
|
||||
}
|
||||
// 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'
|
||||
))) {
|
||||
// Selector-backed page findings honor scoped waivers here too, matching
|
||||
// the browser pass: resolve the selector and drop the finding when an
|
||||
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
||||
// selector keeps the finding — static scans see partial documents.
|
||||
if (f.selector) {
|
||||
let matches = null;
|
||||
try {
|
||||
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
||||
} catch { matches = null; }
|
||||
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
||||
}
|
||||
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