Bug fixes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
438
.agents/skills/impeccable/scripts/detector/cli/main.mjs
Normal file
438
.agents/skills/impeccable/scripts/detector/cli/main.mjs
Normal file
@@ -0,0 +1,438 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
import { detectText } from '../engines/regex/detect-text.mjs';
|
||||
import {
|
||||
filterDetectionFindings,
|
||||
readDetectionConfig,
|
||||
shouldIgnoreDetectionFile,
|
||||
} from '../../lib/impeccable-config.mjs';
|
||||
import {
|
||||
HTML_EXTENSIONS,
|
||||
buildImportGraph,
|
||||
detectFrameworkConfig,
|
||||
isPortListening,
|
||||
walkDir,
|
||||
} from '../node/file-system.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
grouped[f.file].push(f);
|
||||
}
|
||||
const out = [];
|
||||
for (const [file, items] of Object.entries(grouped)) {
|
||||
const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
|
||||
out.push(`\n${file}${importNote}`);
|
||||
for (const item of items) {
|
||||
out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function confirm(question) {
|
||||
const rl = (await import('node:readline')).default.createInterface({
|
||||
input: process.stdin, output: process.stderr,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} [Y/n] `, (answer) => {
|
||||
rl.close();
|
||||
resolve(!answer || /^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
|
||||
|
||||
Scan files or URLs for UI anti-patterns and design quality issues.
|
||||
|
||||
Options:
|
||||
--json Output results as JSON
|
||||
--quiet In text mode, only print the final findings count
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--viewport <WxH> Browser viewport for URL scans (default 1280x800),
|
||||
e.g. --viewport 390x844 for a mobile-width pass
|
||||
--no-config Do not apply project config, detector ignores, inline
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
|
||||
Inline ignores:
|
||||
In-file comments waive a finding where it lives and travel with the file:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
|
||||
// impeccable-disable-next-line bounce-easing: intentional bounce
|
||||
impeccable-disable applies to the whole file; -line / -next-line are scoped.
|
||||
List one or more rule ids (comma-separated), or omit them / use * for all.
|
||||
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
URLs Puppeteer full browser rendering (auto-detected;
|
||||
http(s):// and file:// URLs)
|
||||
|
||||
Examples:
|
||||
impeccable detect src/
|
||||
impeccable detect index.html
|
||||
impeccable detect https://example.com
|
||||
impeccable detect --json .
|
||||
impeccable detect --no-config src/`);
|
||||
}
|
||||
|
||||
async function detectCli() {
|
||||
let args = process.argv.slice(2).map(arg => {
|
||||
if (arg === '-json') return '--json';
|
||||
if (arg === '-fast') return '--fast';
|
||||
return arg;
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
// but ignore it and run the full scan.
|
||||
if (args.includes('--fast')) {
|
||||
process.stderr.write(
|
||||
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
|
||||
);
|
||||
}
|
||||
if (args.includes('--gpt') || args.includes('--gemini')) {
|
||||
process.stderr.write(
|
||||
'Note: --gpt and --gemini are deprecated and ignored. Generated-UI tells now run by default.\n',
|
||||
);
|
||||
}
|
||||
const configEnabled = !args.includes('--no-config');
|
||||
const detectionConfig = configEnabled
|
||||
? readDetectionConfig(process.cwd())
|
||||
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
let viewport = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--viewport' && !args[i].startsWith('--viewport=')) continue;
|
||||
const inline = args[i].startsWith('--viewport=');
|
||||
const value = inline ? args[i].slice('--viewport='.length) : args[i + 1];
|
||||
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value || '');
|
||||
if (!match) {
|
||||
process.stderr.write('Error: --viewport requires a WxH value, e.g. --viewport 390x844\n');
|
||||
process.exit(1);
|
||||
}
|
||||
viewport = { width: Number(match[1]), height: Number(match[2]) };
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
// real cascade, real computed styles, real layout. Callers that want a
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlRe = /^(?:https?|file):\/\//i;
|
||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
if (!jsonMode && !quietMode) {
|
||||
const fwConfig = detectFrameworkConfig(resolved);
|
||||
if (fwConfig) {
|
||||
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
||||
if (probe.listening && probe.matched) {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
|
||||
`For more accurate results, scan the running site:\n` +
|
||||
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
||||
);
|
||||
} else if (probe.listening && !probe.matched) {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
||||
`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
||||
`Start the dev server and scan via URL for best results:\n` +
|
||||
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
|
||||
process.stderr.write(
|
||||
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
||||
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
|
||||
`Target a specific subdirectory to narrow scope.\n`
|
||||
);
|
||||
const ok = await confirm('Continue?');
|
||||
if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
for (const imported of imports) {
|
||||
if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
|
||||
importedByMap.get(imported).add(importer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browserDetector) await browserDetector.close();
|
||||
}
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
983
.agents/skills/impeccable/scripts/detector/design-system.mjs
Normal file
983
.agents/skills/impeccable/scripts/detector/design-system.mjs
Normal file
@@ -0,0 +1,983 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
import { GENERIC_FONTS } from './shared/constants.mjs';
|
||||
import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
|
||||
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
|
||||
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;
|
||||
const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
|
||||
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDesignMdPath(cwd = process.cwd()) {
|
||||
const root = firstExisting(cwd, DESIGN_NAMES);
|
||||
if (root) return { path: root, contextDir: cwd };
|
||||
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const dir = path.resolve(cwd, rel);
|
||||
const found = firstExisting(dir, DESIGN_NAMES);
|
||||
if (found) return { path: found, contextDir: dir };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
path.join(cwd, '.impeccable', 'design.json'),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
path.join(contextDir, 'DESIGN.json'),
|
||||
];
|
||||
return candidates.find((candidate, index) =>
|
||||
candidates.indexOf(candidate) === index && fs.existsSync(candidate)
|
||||
) || null;
|
||||
}
|
||||
|
||||
function parseFrontmatter(md) {
|
||||
const lines = String(md || '').split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== '---') return null;
|
||||
let end = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') { end = i; break; }
|
||||
}
|
||||
if (end === -1) return null;
|
||||
try {
|
||||
return parseYamlSubset(lines.slice(1, end).join('\n'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseYamlSubset(yaml) {
|
||||
const root = {};
|
||||
const stack = [{ indent: -1, obj: root }];
|
||||
|
||||
for (const raw of String(yaml || '').split(/\r?\n/)) {
|
||||
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
||||
const indent = raw.match(/^\s*/)[0].length;
|
||||
const content = raw.slice(indent);
|
||||
const colonIdx = findTopLevelColon(content);
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
|
||||
|
||||
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
|
||||
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
|
||||
const parent = stack[stack.length - 1].obj;
|
||||
|
||||
if (rest === '') {
|
||||
const obj = {};
|
||||
parent[key] = obj;
|
||||
stack.push({ indent, obj });
|
||||
} else {
|
||||
parent[key] = parseScalar(rest);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function findTopLevelColon(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ':') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function unquoteYamlKey(key) {
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
return key.slice(1, -1);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function stripInlineYamlComment(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
|
||||
return s.slice(0, i).trimEnd();
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === 'null' || s === '~') return null;
|
||||
if (/^-?\d+$/.test(s)) return Number(s);
|
||||
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFontName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\s*!important\s*$/i, '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function splitFontStack(stack) {
|
||||
return String(stack || '')
|
||||
.replace(/\s*!important\s*$/i, '')
|
||||
.split(',')
|
||||
.map(normalizeFontName)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function primaryFont(stack) {
|
||||
if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return '';
|
||||
return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || '';
|
||||
}
|
||||
|
||||
function isLiteralFontStack(stack) {
|
||||
const text = String(stack || '');
|
||||
return !/[$`{}]|\s\+\s|\|\|/.test(text);
|
||||
}
|
||||
|
||||
function cssColorLabel(raw) {
|
||||
return String(raw || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function colorKey(color) {
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b}`;
|
||||
}
|
||||
|
||||
function colorsClose(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Math.max(
|
||||
Math.abs(a.r - b.r),
|
||||
Math.abs(a.g - b.g),
|
||||
Math.abs(a.b - b.b),
|
||||
) <= COLOR_CHANNEL_TOLERANCE;
|
||||
}
|
||||
|
||||
function hslToRgb(H, S, L, alpha = 1) {
|
||||
const h = (((H % 360) + 360) % 360) / 360;
|
||||
const s = Math.max(0, Math.min(1, S));
|
||||
const l = Math.max(0, Math.min(1, L));
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return {
|
||||
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
g: Math.round(hue2rgb(p, q, h) * 255),
|
||||
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDesignColor(value) {
|
||||
const text = String(value || '').trim();
|
||||
const parsed = parseAnyColor(text);
|
||||
if (parsed) return parsed;
|
||||
const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i);
|
||||
if (hsl) {
|
||||
return hslToRgb(
|
||||
parseFloat(hsl[1]),
|
||||
parseFloat(hsl[2]) / 100,
|
||||
parseFloat(hsl[3]) / 100,
|
||||
hsl[4] !== undefined ? parseFloat(hsl[4]) : 1,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addDesignColor(out, value, label) {
|
||||
const parsed = parseDesignColor(value);
|
||||
if (!parsed) return;
|
||||
const key = colorKey(parsed);
|
||||
if (!out.allowedColorKeys.has(key)) {
|
||||
out.allowedColorKeys.set(key, { color: parsed, labels: [] });
|
||||
}
|
||||
out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value));
|
||||
}
|
||||
|
||||
function addColorObject(out, colors, prefix = 'colors') {
|
||||
if (!colors || typeof colors !== 'object') return;
|
||||
for (const [name, value] of Object.entries(colors)) {
|
||||
if (typeof value === 'string') {
|
||||
addDesignColor(out, value, `${prefix}.${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSidecarColors(out, sidecar) {
|
||||
const colorMeta = sidecar?.extensions?.colorMeta;
|
||||
if (!colorMeta || typeof colorMeta !== 'object') return;
|
||||
|
||||
for (const [name, meta] of Object.entries(colorMeta)) {
|
||||
if (!meta || typeof meta !== 'object') continue;
|
||||
if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`);
|
||||
if (Array.isArray(meta.tonalRamp)) {
|
||||
for (const [index, value] of meta.tonalRamp.entries()) {
|
||||
if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addTypographyFonts(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
for (const role of Object.values(typography)) {
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
if (typeof role.fontFamily !== 'string') continue;
|
||||
for (const font of splitFontStack(role.fontFamily)) {
|
||||
if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFontSizeStep(out, raw, { fluid = false } = {}) {
|
||||
const text = String(raw ?? '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return;
|
||||
out.allowedFontSizes.push({ value: text, px, fluid });
|
||||
}
|
||||
|
||||
// Split a fluid value into its three terms, or null when it is not a
|
||||
// well-formed clamp(). Used both to read DESIGN.md's fluid roles and to
|
||||
// validate fluid values in source, so the two stay symmetric.
|
||||
function parseClampArgs(raw) {
|
||||
const match = /^clamp\(\s*([\s\S]+)\s*\)$/i.exec(String(raw ?? '').trim());
|
||||
if (!match) return null;
|
||||
const args = splitTopLevelArgs(match[1]);
|
||||
return args.length === 3 ? args : null;
|
||||
}
|
||||
|
||||
// A fluid role declares its two fixed endpoints and interpolates between them
|
||||
// with a viewport unit. Both endpoints are documented sizes, so they belong in
|
||||
// the allowlist; the middle term is viewport-relative and never a fixed step.
|
||||
// Endpoints are marked `fluid` because they do not *enumerate* a ramp: see
|
||||
// `hasFontSizes` below for why that distinction has to survive.
|
||||
function addClampEndpoints(out, raw) {
|
||||
const args = parseClampArgs(raw);
|
||||
if (!args) return false;
|
||||
addFontSizeStep(out, args[0], { fluid: true });
|
||||
addFontSizeStep(out, args[2], { fluid: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitTopLevelArgs(s) {
|
||||
const args = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const ch of String(s)) {
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth--;
|
||||
if (ch === ',' && depth === 0) {
|
||||
args.push(current.trim());
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) args.push(current.trim());
|
||||
return args;
|
||||
}
|
||||
|
||||
function addTypographySizes(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
|
||||
// `scale` is the enumerated ramp: a name -> size map, since the frontmatter
|
||||
// parser has no list support. It sits alongside the named roles.
|
||||
const scale = typography.scale;
|
||||
if (scale && typeof scale === 'object') {
|
||||
for (const value of Object.values(scale)) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') continue;
|
||||
addFontSizeStep(out, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, role] of Object.entries(typography)) {
|
||||
if (name === 'scale') continue;
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
const raw = String(role.fontSize ?? '').trim().toLowerCase();
|
||||
if (addClampEndpoints(out, raw)) continue;
|
||||
addFontSizeStep(out, raw);
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedScale(out, rounded) {
|
||||
if (!rounded || typeof rounded !== 'object') return;
|
||||
for (const [rawName, value] of Object.entries(rounded)) {
|
||||
const name = unquoteYamlKey(rawName).toLowerCase();
|
||||
addRoundedToken(out, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedToken(out, name, value) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return;
|
||||
const raw = String(value).trim();
|
||||
if (!raw || /var\(/i.test(raw) || raw.includes('%')) return;
|
||||
const px = resolveLengthPx(raw, 16);
|
||||
if (px == null || !Number.isFinite(px)) return;
|
||||
out.allowedRadii.push({ name, value: raw, px });
|
||||
if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true;
|
||||
}
|
||||
|
||||
function addSidecarRadii(out, sidecar) {
|
||||
const roundedMeta = sidecar?.extensions?.roundedMeta;
|
||||
if (!roundedMeta || typeof roundedMeta !== 'object') return;
|
||||
|
||||
for (const [rawName, meta] of Object.entries(roundedMeta)) {
|
||||
const name = unquoteYamlKey(rawName).toLowerCase();
|
||||
if (typeof meta === 'string' || typeof meta === 'number') {
|
||||
addRoundedToken(out, `sidecar.${name}`, meta);
|
||||
continue;
|
||||
}
|
||||
if (!meta || typeof meta !== 'object') continue;
|
||||
for (const key of ['canonical', 'value']) {
|
||||
if (typeof meta[key] === 'string' || typeof meta[key] === 'number') {
|
||||
addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]);
|
||||
}
|
||||
}
|
||||
for (const key of ['values', 'aliases']) {
|
||||
if (!Array.isArray(meta[key])) continue;
|
||||
for (const [index, value] of meta[key].entries()) {
|
||||
addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value);
|
||||
}
|
||||
}
|
||||
if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) {
|
||||
out.hasPillRadius = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
const out = {
|
||||
present: true,
|
||||
sourcePath: input.sourcePath || null,
|
||||
sidecarPath: input.sidecarPath || null,
|
||||
mdNewerThanJson: input.mdNewerThanJson === true,
|
||||
allowedFonts: new Set(),
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(out, frontmatter.typography);
|
||||
addTypographySizes(out, frontmatter.typography);
|
||||
addColorObject(out, frontmatter.colors);
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
out.hasRadii = out.allowedRadii.length > 0;
|
||||
// Gate on *enumerated* steps only. A fully fluid system declares clamp
|
||||
// endpoints but no discrete ramp, so treating those endpoints as the whole
|
||||
// allowlist would flag every intermediate size. Abstain instead.
|
||||
out.hasFontSizes = out.allowedFontSizes.some(entry => !entry.fluid);
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
const md = resolveDesignMdPath(cwd);
|
||||
if (!md) return null;
|
||||
|
||||
let frontmatter = null;
|
||||
let mdStat = null;
|
||||
try {
|
||||
mdStat = fs.statSync(md.path);
|
||||
frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!frontmatter || typeof frontmatter !== 'object') return null;
|
||||
|
||||
const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir);
|
||||
const sidecar = safeReadJson(sidecarPath);
|
||||
let sidecarStat = null;
|
||||
try {
|
||||
if (sidecarPath) sidecarStat = fs.statSync(sidecarPath);
|
||||
} catch {
|
||||
sidecarStat = null;
|
||||
}
|
||||
|
||||
return normalizeDesignSystem({
|
||||
frontmatter,
|
||||
sidecar,
|
||||
sourcePath: md.path,
|
||||
sidecarPath,
|
||||
mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000),
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
return designSystem.allowedFonts.has(font);
|
||||
}
|
||||
|
||||
function isAllowedColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasColors) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
|
||||
if (text.includes('var(')) return true;
|
||||
const parsed = parseDesignColor(text);
|
||||
if (!parsed) return true;
|
||||
if ((parsed.a ?? 1) <= 0.05) return true;
|
||||
for (const entry of designSystem.allowedColorKeys.values()) {
|
||||
if (colorsClose(parsed, entry.color)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
|
||||
if (text.includes('var(') || text.includes('%')) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true;
|
||||
if (designSystem.hasPillRadius && px >= 99) return true;
|
||||
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
// One term of a font-size value. `unjudgeable` covers var(), calc(), percentages
|
||||
// and units the ramp cannot resolve (em is parent-relative, not root-relative);
|
||||
// those abstain rather than guess.
|
||||
function fontSizeStepStatus(raw, designSystem) {
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return 'unjudgeable';
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return 'unjudgeable';
|
||||
return designSystem.allowedFontSizes.some(
|
||||
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
|
||||
) ? 'on-ramp' : 'off-ramp';
|
||||
}
|
||||
|
||||
// The off-ramp endpoints of a fluid value, or null when `raw` is not a fluid
|
||||
// value at all. Only the min and max are judged: the viewport term interpolates
|
||||
// between them and is never a fixed step.
|
||||
//
|
||||
// Reading clamp endpoints as documented steps without also checking them in
|
||||
// usage would let `clamp(99rem, 1vw, 200rem)` through, which is how a fluid
|
||||
// declaration stayed invisible until someone measured computed styles.
|
||||
export function offRampClampEndpoints(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return null;
|
||||
const args = parseClampArgs(String(raw || '').trim().replace(/\s*!important\s*$/i, ''));
|
||||
if (!args) return null;
|
||||
return [args[0], args[2]].filter(
|
||||
endpoint => fontSizeStepStatus(endpoint, designSystem) === 'off-ramp',
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedFontSizeRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return true;
|
||||
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
|
||||
const offRampEndpoints = offRampClampEndpoints(text, designSystem);
|
||||
if (offRampEndpoints) return offRampEndpoints.length === 0;
|
||||
return fontSizeStepStatus(text, designSystem) !== 'off-ramp';
|
||||
}
|
||||
|
||||
function lineLooksCommented(line) {
|
||||
const trimmed = String(line || '').trim();
|
||||
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
|
||||
}
|
||||
|
||||
function isProbablyColorLiteral(line, match) {
|
||||
const raw = match?.[0] || '';
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
if (isInsideCssAttributeSelector(line, index)) return false;
|
||||
|
||||
const before = line.slice(0, index);
|
||||
const after = line.slice(index + raw.length);
|
||||
|
||||
if (raw.startsWith('#')) {
|
||||
if (before.endsWith('&')) return false; // HTML numeric entity, e.g. ↔
|
||||
|
||||
const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';
|
||||
const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';
|
||||
if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155
|
||||
}
|
||||
|
||||
const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);
|
||||
const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);
|
||||
const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);
|
||||
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
const lastOpen = before.lastIndexOf('[');
|
||||
if (lastOpen === -1) return false;
|
||||
const lastClose = before.lastIndexOf(']');
|
||||
if (lastClose > lastOpen) return false;
|
||||
const after = line.slice(index);
|
||||
const close = after.indexOf(']');
|
||||
const block = after.indexOf('{');
|
||||
return close !== -1 && (block === -1 || close < block);
|
||||
}
|
||||
|
||||
function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {
|
||||
return { ...finding(id, filePath, snippet, line), ...extras };
|
||||
}
|
||||
|
||||
function decodeGoogleFamily(value) {
|
||||
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
|
||||
try {
|
||||
return decodeURIComponent(family);
|
||||
} catch {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
function checkFontStack(stack, filePath, line, designSystem, context) {
|
||||
const primary = primaryFont(stack);
|
||||
if (!primary || isAllowedFont(primary, designSystem)) return [];
|
||||
const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());
|
||||
return [makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`${context}: ${display} is not declared in DESIGN.md typography`,
|
||||
line,
|
||||
{ ignoreValue: display },
|
||||
)];
|
||||
}
|
||||
|
||||
function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
const findings = [];
|
||||
for (const token of extractRadiusTokens(value)) {
|
||||
if (isAllowedRadiusRaw(token, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-radius',
|
||||
filePath,
|
||||
`${context}: ${token} is outside the DESIGN.md rounded scale`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkFontSizeValue(value, filePath, line, designSystem, context) {
|
||||
const token = String(value || '').trim();
|
||||
if (isAllowedFontSizeRaw(token, designSystem)) return [];
|
||||
|
||||
// Name the offending endpoint on a fluid value; the whole clamp() string is
|
||||
// not actionable on its own, and it makes a poor ignore-value.
|
||||
const offRampEndpoints = offRampClampEndpoints(token, designSystem) || [];
|
||||
if (offRampEndpoints.length > 0) {
|
||||
const plural = offRampEndpoints.length > 1 ? 's' : '';
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} has fluid endpoint${plural} ${offRampEndpoints.join(' and ')} off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: offRampEndpoints[0] },
|
||||
)];
|
||||
}
|
||||
|
||||
// The snippet shows the declaration as authored, but the ignoreValue has to
|
||||
// be what a `hooks ignore-value` waiver can match, so the priority marker is
|
||||
// stripped. Otherwise the same size needs two different waivers depending on
|
||||
// whether it carries !important. font-family already behaves this way.
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} is off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: token.replace(/\s*!important\s*$/i, '').trim() },
|
||||
)];
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
|
||||
const findings = [];
|
||||
const lines = String(content || '').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
if (lineLooksCommented(line)) continue;
|
||||
|
||||
if (designSystem.hasFonts) {
|
||||
for (const match of line.matchAll(FONT_DECL_RE)) {
|
||||
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_JS_RE)) {
|
||||
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));
|
||||
}
|
||||
for (const match of line.matchAll(GOOGLE_FONT_RE)) {
|
||||
const url = match[0];
|
||||
for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {
|
||||
const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));
|
||||
if (!font || isAllowedFont(font, designSystem)) continue;
|
||||
const display = decodeGoogleFamily(familyMatch[1]);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`Google Fonts: ${display} is not declared in DESIGN.md typography`,
|
||||
lineNum,
|
||||
{ ignoreValue: display },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
for (const match of line.matchAll(CSS_COLOR_RE)) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
`Undocumented color ${raw} is outside DESIGN.md colors`,
|
||||
lineNum,
|
||||
{ ignoreValue: raw },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
for (const match of line.matchAll(BORDER_RADIUS_RE)) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));
|
||||
}
|
||||
for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasFontSizes) {
|
||||
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
|
||||
}
|
||||
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
}
|
||||
|
||||
function hasDirectText(el) {
|
||||
return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
function sampleText(el) {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
|
||||
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
const seenFonts = new Set();
|
||||
const seenColors = new Set();
|
||||
const seenRadii = new Set();
|
||||
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (shouldSkipStaticDesignElement(el, window)) continue;
|
||||
const tag = el.tagName?.toLowerCase?.() || 'unknown';
|
||||
const style = window.getComputedStyle(el);
|
||||
|
||||
if (designSystem.hasFonts && hasDirectText(el)) {
|
||||
const font = primaryFont(style.fontFamily || '');
|
||||
if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {
|
||||
seenFonts.add(font);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
|
||||
0,
|
||||
{ ignoreValue: font },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
const colorChecks = [];
|
||||
if (hasDirectText(el)) colorChecks.push(['text color', style.color]);
|
||||
if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
|
||||
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
|
||||
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
|
||||
}
|
||||
}
|
||||
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
|
||||
|
||||
for (const [kind, raw] of colorChecks) {
|
||||
const label = cssColorLabel(raw);
|
||||
if (isAllowedColorRaw(label, designSystem)) continue;
|
||||
const key = `${kind}:${label}`;
|
||||
if (seenColors.has(key)) continue;
|
||||
seenColors.add(key);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,
|
||||
0,
|
||||
{ ignoreValue: label },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
const rawRadius = String(style.borderRadius || '').trim();
|
||||
if (!rawRadius) continue;
|
||||
for (const token of extractRadiusTokens(rawRadius)) {
|
||||
if (isAllowedRadiusRaw(token, designSystem)) continue;
|
||||
if (seenRadii.has(token)) continue;
|
||||
seenRadii.add(token);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-radius',
|
||||
filePath,
|
||||
`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,
|
||||
0,
|
||||
{ ignoreValue: token },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function shouldSkipStaticDesignElement(el, window) {
|
||||
const tag = el.tagName?.toLowerCase?.() || '';
|
||||
if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;
|
||||
|
||||
let current = el;
|
||||
while (current) {
|
||||
if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;
|
||||
const style = window.getComputedStyle(current);
|
||||
const display = String(style.display || '').toLowerCase();
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTransparentCss(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent') return true;
|
||||
const parsed = parseDesignColor(text);
|
||||
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
|
||||
}
|
||||
|
||||
function canonicalDesignFindingKey(item) {
|
||||
if (!item?.antipattern?.startsWith?.('design-system-')) return null;
|
||||
const value = item.ignoreValue || item.value || '';
|
||||
if (item.antipattern === 'design-system-font') {
|
||||
const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';
|
||||
const font = normalizeFontName(value);
|
||||
return font ? `${item.antipattern}:${context}:${font}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-color') {
|
||||
const parsed = parseDesignColor(value);
|
||||
if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;
|
||||
const label = cssColorLabel(value).toLowerCase();
|
||||
return label ? `${item.antipattern}:color:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-radius') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-font-size') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:font-size:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeDesignSystemFindings(...groups) {
|
||||
const out = [];
|
||||
const seen = new Map();
|
||||
for (const group of groups) {
|
||||
for (const item of group || []) {
|
||||
const key = canonicalDesignFindingKey(item);
|
||||
if (key) {
|
||||
if (seen.has(key)) {
|
||||
const existing = out[seen.get(key)];
|
||||
if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;
|
||||
continue;
|
||||
}
|
||||
seen.set(key, out.length);
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupeDesignFindings(findings) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const item of findings) {
|
||||
const key = [
|
||||
item.antipattern,
|
||||
item.line || 0,
|
||||
normalizeFontName(item.ignoreValue || item.snippet || ''),
|
||||
].join('\0');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export {
|
||||
parseFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Anti-Pattern Detector for Impeccable
|
||||
* Copyright (c) 2026 Paul Bakaus
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Public API facade. Runtime engines live under cli/engine/engines/.
|
||||
*/
|
||||
|
||||
import { detectCli } from './cli/main.mjs';
|
||||
|
||||
export { ANTIPATTERNS, RULE_ENGINE_SUPPORT, getAntipattern, getRulesForCategory, getRuleEngineSupport } from './registry/antipatterns.mjs';
|
||||
export { SAFE_TAGS, BORDER_SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, KNOWN_SERIF_FONTS } from './shared/constants.mjs';
|
||||
export { isNeutralColor, parseRgb, relativeLuminance, contrastRatio, parseGradientColors, hasChroma, getHue, colorToHex } from './shared/color.mjs';
|
||||
export { isFullPage } from './shared/page.mjs';
|
||||
export {
|
||||
checkElementBorders,
|
||||
checkElementMotion,
|
||||
checkElementGlow,
|
||||
checkPageTypography,
|
||||
checkPageLayout,
|
||||
checkHtmlPatterns,
|
||||
} from './rules/checks.mjs';
|
||||
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
|
||||
export {
|
||||
parseFrontmatter as parseDesignFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
} from './design-system.mjs';
|
||||
export { detectHtml } from './engines/static-html/detect-html.mjs';
|
||||
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
|
||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||
export {
|
||||
walkDir,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
SKIP_DIRS,
|
||||
buildImportGraph,
|
||||
resolveImport,
|
||||
detectFrameworkConfig,
|
||||
isPortListening,
|
||||
FRAMEWORK_CONFIGS,
|
||||
} from './node/file-system.mjs';
|
||||
export { formatFindings, detectCli } from './cli/main.mjs';
|
||||
|
||||
const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
|
||||
process.argv[1]?.endsWith('detect-antipatterns.mjs/');
|
||||
if (isMainModule) detectCli();
|
||||
@@ -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,
|
||||
};
|
||||
18
.agents/skills/impeccable/scripts/detector/findings.mjs
Normal file
18
.agents/skills/impeccable/scripts/detector/findings.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getAntipattern } from './registry/antipatterns.mjs';
|
||||
|
||||
function getAP(id) {
|
||||
return getAntipattern(id);
|
||||
}
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
198
.agents/skills/impeccable/scripts/detector/node/file-system.mjs
Normal file
198
.agents/skills/impeccable/scripts/detector/node/file-system.mjs
Normal file
@@ -0,0 +1,198 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
|
||||
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
|
||||
]);
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
'.vue', '.svelte', '.astro',
|
||||
]);
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||
|
||||
function walkDir(dir) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full));
|
||||
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import graph (multi-file awareness)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveImport(specifier, fromDir, fileSet) {
|
||||
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
|
||||
const base = path.resolve(fromDir, specifier);
|
||||
if (fileSet.has(base)) return base;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const withExt = base + ext;
|
||||
if (fileSet.has(withExt)) return withExt;
|
||||
}
|
||||
// index file convention
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const indexFile = path.join(base, 'index' + ext);
|
||||
if (fileSet.has(indexFile)) return indexFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
// ES imports: import ... from '...' and import '...'
|
||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = esRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// CSS @import
|
||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
||||
while ((m = cssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// SCSS @use / @forward
|
||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = scssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
graph.set(file, imports);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framework dev server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FRAMEWORK_CONFIGS = [
|
||||
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /next/i } },
|
||||
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-sveltekit-page', value: null } },
|
||||
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
|
||||
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /@vite\/client/ } },
|
||||
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /astro/i } },
|
||||
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
|
||||
portRe: /"port"\s*:\s*(\d+)/,
|
||||
fingerprint: { body: /ng-version/i } },
|
||||
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /remix/i } },
|
||||
];
|
||||
|
||||
function detectFrameworkConfig(dir) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch { return null; }
|
||||
const entrySet = new Set(entries);
|
||||
|
||||
for (const cfg of FRAMEWORK_CONFIGS) {
|
||||
const match = cfg.files.find(f => entrySet.has(f));
|
||||
if (!match) continue;
|
||||
|
||||
const configPath = path.join(dir, match);
|
||||
let port = cfg.defaultPort;
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const portMatch = content.match(cfg.portRe);
|
||||
if (portMatch) port = parseInt(portMatch[1], 10);
|
||||
} catch { /* use default */ }
|
||||
|
||||
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is listening and optionally verify it matches the expected framework.
|
||||
* Returns { listening: true, matched: true/false } or { listening: false }.
|
||||
*/
|
||||
async function isPortListening(port, fingerprint = null) {
|
||||
if (!fingerprint) {
|
||||
// Simple TCP probe fallback
|
||||
const net = await import('node:net');
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
|
||||
sock.setTimeout(500);
|
||||
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
|
||||
sock.on('error', () => resolve({ listening: false }));
|
||||
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP probe with fingerprint matching
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Check header fingerprint
|
||||
if (fingerprint.header) {
|
||||
const val = res.headers.get(fingerprint.header);
|
||||
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Check body fingerprint
|
||||
if (fingerprint.body) {
|
||||
const body = await res.text();
|
||||
if (fingerprint.body.test(body)) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Port is listening but doesn't match the expected framework
|
||||
return { listening: true, matched: false };
|
||||
} catch {
|
||||
return { listening: false };
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
SKIP_DIRS,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
HTML_EXTENSIONS,
|
||||
walkDir,
|
||||
resolveImport,
|
||||
buildImportGraph,
|
||||
FRAMEWORK_CONFIGS,
|
||||
detectFrameworkConfig,
|
||||
isPortListening,
|
||||
};
|
||||
166
.agents/skills/impeccable/scripts/detector/profile/profiler.mjs
Normal file
166
.agents/skills/impeccable/scripts/detector/profile/profiler.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
function profileNow() {
|
||||
return typeof performance !== 'undefined' && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
function createDetectorProfile() {
|
||||
return { events: [] };
|
||||
}
|
||||
|
||||
function recordProfileEvent(profile, event) {
|
||||
if (!profile) return;
|
||||
const normalized = {
|
||||
engine: event.engine || 'unknown',
|
||||
phase: event.phase || 'unknown',
|
||||
ruleId: event.ruleId || 'unknown',
|
||||
target: event.target || '',
|
||||
ms: Number.isFinite(event.ms) ? event.ms : 0,
|
||||
findings: Number.isFinite(event.findings) ? event.findings : 0,
|
||||
};
|
||||
if (event.detail) normalized.detail = event.detail;
|
||||
if (Array.isArray(event.findingIds) && event.findingIds.length) {
|
||||
normalized.findingIds = event.findingIds;
|
||||
}
|
||||
if (typeof profile === 'function') {
|
||||
profile(normalized);
|
||||
} else if (typeof profile.record === 'function') {
|
||||
profile.record(normalized);
|
||||
} else if (Array.isArray(profile.events)) {
|
||||
profile.events.push(normalized);
|
||||
} else if (Array.isArray(profile)) {
|
||||
profile.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function extractFindingIds(findings) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))];
|
||||
}
|
||||
|
||||
function profileFindings(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
const findings = callback();
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: Array.isArray(findings) ? findings.length : 0,
|
||||
findingIds: extractFindingIds(findings),
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
function profileStep(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function profileFindingsAsync(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
const findings = await callback();
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: Array.isArray(findings) ? findings.length : 0,
|
||||
findingIds: extractFindingIds(findings),
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function profileStepAsync(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(sortedValues, pct) {
|
||||
if (!sortedValues.length) return 0;
|
||||
const idx = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1),
|
||||
);
|
||||
return sortedValues[idx];
|
||||
}
|
||||
|
||||
function summarizeDetectorProfile(profile) {
|
||||
const events = Array.isArray(profile)
|
||||
? profile
|
||||
: (Array.isArray(profile?.events) ? profile.events : []);
|
||||
const groups = new Map();
|
||||
for (const event of events) {
|
||||
const key = [
|
||||
event.engine || 'unknown',
|
||||
event.phase || 'unknown',
|
||||
event.ruleId || 'unknown',
|
||||
event.target || '',
|
||||
].join('\u0000');
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
engine: event.engine || 'unknown',
|
||||
phase: event.phase || 'unknown',
|
||||
ruleId: event.ruleId || 'unknown',
|
||||
target: event.target || '',
|
||||
calls: 0,
|
||||
totalMs: 0,
|
||||
findings: 0,
|
||||
samples: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
const ms = Number.isFinite(event.ms) ? event.ms : 0;
|
||||
group.calls += 1;
|
||||
group.totalMs += ms;
|
||||
group.findings += Number.isFinite(event.findings) ? event.findings : 0;
|
||||
group.samples.push(ms);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map(group => {
|
||||
const samples = group.samples.sort((a, b) => a - b);
|
||||
return {
|
||||
engine: group.engine,
|
||||
phase: group.phase,
|
||||
ruleId: group.ruleId,
|
||||
target: group.target,
|
||||
calls: group.calls,
|
||||
totalMs: Number(group.totalMs.toFixed(3)),
|
||||
avgMs: Number((group.totalMs / group.calls).toFixed(3)),
|
||||
p50: Number(percentile(samples, 50).toFixed(3)),
|
||||
p95: Number(percentile(samples, 95).toFixed(3)),
|
||||
findings: group.findings,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalMs - a.totalMs);
|
||||
}
|
||||
|
||||
export {
|
||||
profileNow,
|
||||
createDetectorProfile,
|
||||
recordProfileEvent,
|
||||
extractFindingIds,
|
||||
profileFindings,
|
||||
profileStep,
|
||||
profileFindingsAsync,
|
||||
profileStepAsync,
|
||||
percentile,
|
||||
summarizeDetectorProfile,
|
||||
};
|
||||
@@ -0,0 +1,619 @@
|
||||
const ANTIPATTERNS = [
|
||||
// ── AI slop: tells that something was AI-generated ──
|
||||
{
|
||||
id: 'side-tab',
|
||||
category: 'slop',
|
||||
name: 'Side-tab accent border',
|
||||
description:
|
||||
'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'colored accent stripe',
|
||||
},
|
||||
{
|
||||
id: 'border-accent-on-rounded',
|
||||
category: 'slop',
|
||||
name: 'Border accent on rounded element',
|
||||
description:
|
||||
'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'colored accent stripe',
|
||||
},
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'overused fonts like Inter',
|
||||
},
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font without hierarchy',
|
||||
description:
|
||||
'Only one font family is used for the entire page. A single family can work when weight and size contrast carry the hierarchy; otherwise pair a distinctive display font with a refined body font.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'only one font family for the entire page',
|
||||
},
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'flat type hierarchy',
|
||||
},
|
||||
{
|
||||
id: 'gradient-text',
|
||||
category: 'slop',
|
||||
name: 'Gradient text',
|
||||
description:
|
||||
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'gradient text for',
|
||||
},
|
||||
{
|
||||
id: 'ai-color-palette',
|
||||
category: 'slop',
|
||||
name: 'AI color palette',
|
||||
description:
|
||||
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'AI color palette',
|
||||
},
|
||||
{
|
||||
id: 'cream-palette',
|
||||
category: 'slop',
|
||||
name: 'Cream / beige palette',
|
||||
description:
|
||||
'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'cream and beige as the default surface',
|
||||
},
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'Nest cards inside cards',
|
||||
},
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'same spacing everywhere',
|
||||
},
|
||||
{
|
||||
id: 'bounce-easing',
|
||||
category: 'slop',
|
||||
name: 'Bounce or elastic easing',
|
||||
description:
|
||||
'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'bounce or elastic easing',
|
||||
},
|
||||
{
|
||||
id: 'pulsing-dot',
|
||||
category: 'slop',
|
||||
name: 'Pulsing status dot',
|
||||
description:
|
||||
'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'decorative pulsing status dot',
|
||||
},
|
||||
{
|
||||
id: 'blinking-cursor',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Decorative blinking cursor',
|
||||
description:
|
||||
'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
|
||||
skillSection: 'Motion',
|
||||
},
|
||||
{
|
||||
id: 'shape-assembled-illustration',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Shape-assembled illustration',
|
||||
description:
|
||||
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
|
||||
skillSection: 'Imagery',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow',
|
||||
category: 'slop',
|
||||
name: 'Glowing shadow accents',
|
||||
description:
|
||||
'Colored glow shadows — a zero-offset chromatic halo (box- or text-shadow) on any background, or any colored blurred shadow on a dark background — are the default "cool" look of AI-generated UIs. Use neutral elevation shadows and subtle, purposeful lighting instead.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-halo',
|
||||
category: 'slop',
|
||||
name: 'Radial-gradient background halo',
|
||||
description:
|
||||
'A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'marquee',
|
||||
category: 'slop',
|
||||
name: 'Auto-scrolling marquee',
|
||||
description:
|
||||
'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'auto-scrolling marquee',
|
||||
},
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'large icons with rounded corners above every heading',
|
||||
},
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'oversized italic serif as the hero headline',
|
||||
},
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'tiny uppercase tracked label above the hero headline',
|
||||
},
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-labels',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Tiny numbered section labels',
|
||||
description:
|
||||
'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'numbered section markers',
|
||||
},
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
{
|
||||
id: 'marketing-buzzword',
|
||||
category: 'slop',
|
||||
name: 'Marketing buzzword',
|
||||
description:
|
||||
'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'marketing buzzwords',
|
||||
},
|
||||
{
|
||||
id: 'aphoristic-cadence',
|
||||
category: 'slop',
|
||||
name: 'Aphoristic-cadence copy',
|
||||
description:
|
||||
'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'aphoristic cadence',
|
||||
},
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'long headline set at display size',
|
||||
},
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'letter spacing crushed past legibility',
|
||||
},
|
||||
{
|
||||
id: 'broken-image',
|
||||
category: 'quality',
|
||||
name: 'Broken or placeholder image',
|
||||
description:
|
||||
'<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',
|
||||
skillSection: 'Imagery',
|
||||
skillGuideline: 'broken image references',
|
||||
},
|
||||
|
||||
// ── Quality: general design and accessibility issues ──
|
||||
{
|
||||
id: 'script-error',
|
||||
category: 'quality',
|
||||
severity: 'error',
|
||||
name: 'Uncaught script error on load',
|
||||
description:
|
||||
'A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.',
|
||||
},
|
||||
{
|
||||
id: 'content-hidden-at-rest',
|
||||
category: 'quality',
|
||||
severity: 'error',
|
||||
scopes: ['layout'],
|
||||
name: 'Content invisible at rest',
|
||||
description:
|
||||
'A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.',
|
||||
},
|
||||
{
|
||||
id: 'edge-flush-cards',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cards flush against the scroller edge',
|
||||
description:
|
||||
'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.',
|
||||
},
|
||||
{
|
||||
id: 'text-occlusion',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Text occluded by an overlapping element',
|
||||
description:
|
||||
'Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.',
|
||||
skillSection: 'Layout & Space',
|
||||
},
|
||||
{
|
||||
id: 'first-viewport-column-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'One column stretches the first viewport',
|
||||
description:
|
||||
'A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.',
|
||||
skillSection: 'Layout & Space',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
category: 'quality',
|
||||
name: 'Gray text on colored background',
|
||||
description:
|
||||
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'gray text on colored backgrounds',
|
||||
},
|
||||
{
|
||||
id: 'low-contrast',
|
||||
category: 'quality',
|
||||
name: 'Low contrast text',
|
||||
description:
|
||||
'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
|
||||
},
|
||||
{
|
||||
id: 'layout-transition',
|
||||
category: 'quality',
|
||||
name: 'Layout property animation',
|
||||
description:
|
||||
'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'Animate layout properties',
|
||||
},
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'wrap beyond ~80 characters',
|
||||
},
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'inside bordered or colored containers',
|
||||
},
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
},
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
},
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
},
|
||||
{
|
||||
id: 'heading-rhythm',
|
||||
category: 'quality',
|
||||
scopes: ['layout', 'type'],
|
||||
name: 'Heading crowded against the previous block',
|
||||
description:
|
||||
'A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.',
|
||||
skillSection: 'Layout & Space',
|
||||
},
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
},
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'long body passages in uppercase',
|
||||
},
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
},
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'content wider than its container',
|
||||
},
|
||||
{
|
||||
id: 'repeated-container-text',
|
||||
category: 'quality',
|
||||
name: 'Same text repeated inside one container',
|
||||
description:
|
||||
'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.',
|
||||
},
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'overflow container clipping positioned children',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font family outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-color',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Color outside DESIGN.md',
|
||||
description:
|
||||
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'literal color outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-radius',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Radius outside DESIGN.md',
|
||||
description:
|
||||
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Common generated-UI tells ───────────────────────────────────────────
|
||||
{
|
||||
id: 'gpt-thin-border-wide-shadow',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Hairline border with wide shadow',
|
||||
description:
|
||||
'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'hairline border plus wide diffuse shadow',
|
||||
},
|
||||
{
|
||||
id: 'repeating-stripes-gradient',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Repeating-gradient stripes',
|
||||
description:
|
||||
'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'repeating-gradient decorative stripes',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Theater framing copy',
|
||||
description:
|
||||
'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'theater framing copy',
|
||||
},
|
||||
{
|
||||
id: 'image-hover-transform',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Image hover transform',
|
||||
description:
|
||||
'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'image scale or rotate on hover',
|
||||
},
|
||||
];
|
||||
|
||||
const RULE_ENGINE_SUPPORT = {
|
||||
regex: new Set(['source', 'page-analyzer']),
|
||||
'static-html': new Set(['element', 'page']),
|
||||
browser: new Set(['element', 'page', 'layout']),
|
||||
visual: new Set(['visual-contrast']),
|
||||
};
|
||||
|
||||
function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
|
||||
function getRuleEngineSupport(engine) {
|
||||
return RULE_ENGINE_SUPPORT[engine] || new Set();
|
||||
}
|
||||
|
||||
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
|
||||
// CLI --scope flag to narrow output to one design domain.
|
||||
const RULE_SCOPES = new Set(
|
||||
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
|
||||
);
|
||||
|
||||
// Keep only findings whose rule declares at least one of the requested
|
||||
// scopes. An empty scope list means no filtering (default CLI behavior).
|
||||
function filterByScopes(findings, scopes = []) {
|
||||
if (!scopes || scopes.length === 0) return findings;
|
||||
const enabled = new Set(scopes);
|
||||
return findings.filter(f => {
|
||||
const rule = getAntipattern(f.antipattern);
|
||||
return (rule?.scopes || []).some(scope => enabled.has(scope));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
5282
.agents/skills/impeccable/scripts/detector/rules/checks.mjs
Normal file
5282
.agents/skills/impeccable/scripts/detector/rules/checks.mjs
Normal file
File diff suppressed because it is too large
Load Diff
124
.agents/skills/impeccable/scripts/detector/shared/color.mjs
Normal file
124
.agents/skills/impeccable/scripts/detector/shared/color.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
|
||||
|
||||
function isNeutralColor(color) {
|
||||
if (!color || color === 'transparent') return true;
|
||||
|
||||
// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
|
||||
const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (rgb) {
|
||||
return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
|
||||
}
|
||||
|
||||
// oklch()/lch() — chroma is the second numeric component.
|
||||
// oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
|
||||
// lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
|
||||
// literally (it does NOT convert them to rgb).
|
||||
const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
|
||||
if (oklch) return parseFloat(oklch[1]) < 0.02;
|
||||
const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
|
||||
if (lch) return parseFloat(lch[1]) < 3;
|
||||
|
||||
// oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
|
||||
// oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
|
||||
const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
|
||||
if (oklab) {
|
||||
const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
|
||||
return Math.hypot(a, b) < 0.02;
|
||||
}
|
||||
const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
|
||||
if (lab) {
|
||||
const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
|
||||
return Math.hypot(a, b) < 3;
|
||||
}
|
||||
|
||||
// hsl/hsla — saturation is the second numeric component (percent).
|
||||
// Modern jsdom usually converts hsl() to rgb, but handle it directly for
|
||||
// safety across versions and for any engine that preserves the format.
|
||||
const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
|
||||
if (hsl) return parseFloat(hsl[1]) < 10;
|
||||
|
||||
// hwb(hue whiteness% blackness%) — a pixel is fully gray when
|
||||
// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
|
||||
const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
|
||||
if (hwb) {
|
||||
const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
|
||||
return (1 - Math.min(100, w + b) / 100) < 0.1;
|
||||
}
|
||||
|
||||
// Unknown / unrecognized format — err on the side of DETECTING rather
|
||||
// than silently skipping. This is the opposite of the previous default,
|
||||
// which was the root cause of the oklch bug.
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseRgb(color) {
|
||||
if (!color || color === 'transparent') return null;
|
||||
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (!m) return null;
|
||||
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
|
||||
}
|
||||
|
||||
function relativeLuminance({ r, g, b }) {
|
||||
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
|
||||
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
);
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
function contrastRatio(c1, c2) {
|
||||
const l1 = relativeLuminance(c1);
|
||||
const l2 = relativeLuminance(c2);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
||||
const c = parseRgb(m[0]);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
} else {
|
||||
colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
function hasChroma(c, threshold = 30) {
|
||||
if (!c) return false;
|
||||
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
|
||||
}
|
||||
|
||||
function getHue(c) {
|
||||
if (!c) return 0;
|
||||
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
if (max === min) return 0;
|
||||
const d = max - min;
|
||||
let h;
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
return Math.round(h * 360);
|
||||
}
|
||||
|
||||
function colorToHex(c) {
|
||||
if (!c) return '?';
|
||||
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export {
|
||||
isNeutralColor,
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
contrastRatio,
|
||||
parseGradientColors,
|
||||
hasChroma,
|
||||
getHue,
|
||||
colorToHex,
|
||||
};
|
||||
112
.agents/skills/impeccable/scripts/detector/shared/constants.mjs
Normal file
112
.agents/skills/impeccable/scripts/detector/shared/constants.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
// ─── Section 1: Constants ───────────────────────────────────────────────────
|
||||
|
||||
const SAFE_TAGS = new Set([
|
||||
'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
|
||||
'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
|
||||
'button', 'hr', 'html', 'head', 'body', 'script', 'style',
|
||||
'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
|
||||
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
|
||||
]);
|
||||
|
||||
// Per-check safe-tags override for the border (side-tab / border-accent)
|
||||
// rule. We intentionally re-allow <label> here because card-shaped clickable
|
||||
// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
|
||||
// canonical side-tab anti-pattern shapes and must be detected. The rule's
|
||||
// other preconditions (non-neutral color, width >= 2px on a single side,
|
||||
// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
|
||||
// already filter out plain inline form labels so this does not introduce
|
||||
// false positives. See modern-color-borders.html for the test matrix.
|
||||
const BORDER_SAFE_TAGS = new Set(
|
||||
[...SAFE_TAGS].filter(t => t !== 'label')
|
||||
);
|
||||
|
||||
const OVERUSED_FONTS = new Set([
|
||||
// Older monoculture (still ubiquitous):
|
||||
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
||||
// Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
|
||||
'fraunces', 'instrument sans', 'instrument serif',
|
||||
'geist', 'geist sans', 'geist mono',
|
||||
'mona sans',
|
||||
'plus jakarta sans', 'space grotesk', 'recoleta',
|
||||
]);
|
||||
|
||||
// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
|
||||
// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
|
||||
const GOOGLE_DOMAINS = [
|
||||
'google.com', 'youtube.com', 'android.com', 'chromium.org',
|
||||
'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
|
||||
];
|
||||
const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];
|
||||
const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];
|
||||
const BRAND_FONT_DOMAINS = {
|
||||
'roboto': GOOGLE_DOMAINS,
|
||||
'google sans': GOOGLE_DOMAINS,
|
||||
'product sans': GOOGLE_DOMAINS,
|
||||
'geist': VERCEL_DOMAINS,
|
||||
'geist sans': VERCEL_DOMAINS,
|
||||
'geist mono': VERCEL_DOMAINS,
|
||||
'mona sans': GITHUB_DOMAINS,
|
||||
};
|
||||
|
||||
function isBrandFontOnOwnDomain(font) {
|
||||
if (typeof location === 'undefined') return false;
|
||||
const allowed = BRAND_FONT_DOMAINS[font];
|
||||
if (!allowed) return false;
|
||||
const host = location.hostname.toLowerCase();
|
||||
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||
}
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
// WCAG large text thresholds are defined in points: 18pt normal text and
|
||||
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
const KNOWN_SERIF_FONTS = new Set([
|
||||
'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
|
||||
'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
|
||||
'tiempos', 'tiempos headline', 'tiempos text',
|
||||
'lora', 'vollkorn', 'spectral',
|
||||
'source serif pro', 'source serif 4', 'source serif',
|
||||
'ibm plex serif', 'merriweather',
|
||||
'libre caslon', 'libre baskerville', 'baskerville',
|
||||
'georgia', 'times new roman', 'times',
|
||||
'dm serif display', 'dm serif text',
|
||||
'instrument serif', 'gt sectra', 'ogg', 'canela',
|
||||
'freight display', 'freight text',
|
||||
]);
|
||||
|
||||
export {
|
||||
SAFE_TAGS,
|
||||
BORDER_SAFE_TAGS,
|
||||
OVERUSED_FONTS,
|
||||
GOOGLE_DOMAINS,
|
||||
VERCEL_DOMAINS,
|
||||
GITHUB_DOMAINS,
|
||||
BRAND_FONT_DOMAINS,
|
||||
isBrandFontOnOwnDomain,
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
30
.agents/skills/impeccable/scripts/detector/shared/fonts.mjs
Normal file
30
.agents/skills/impeccable/scripts/detector/shared/fonts.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
export { extractGoogleFontFamilies };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Check if content looks like a full page (not a component/partial) */
|
||||
function isFullPage(content) {
|
||||
const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
|
||||
return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
|
||||
}
|
||||
|
||||
export { isFullPage };
|
||||
Reference in New Issue
Block a user