Improve critique skill reliability

- add provider-specific block compilation and tests

- bundle detector scripts for skill critique runs

- harden critique orchestration, browser handling, and storage
This commit is contained in:
Paul Bakaus
2026-05-18 15:15:14 -07:00
parent e1d3ea0b6f
commit bc1894889e
285 changed files with 142401 additions and 1251 deletions
+77 -79
View File
@@ -1,109 +1,96 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Setup: Resolve Target and Load Ignore List
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Before gathering assessments, do two small bookkeeping steps. They cost almost nothing and they're what makes critique iterative across runs.
### Hard Invariants
1. **Resolve the primary artifact.** The user's phrasing ("the homepage", "the pricing flow") is not stable enough to track across runs. Resolve it to a concrete file path or URL: the same one you'd already need to scan code or open in a browser. Examples:
- "the homepage" → `site/pages/index.astro` (or `http://localhost:3000/` if you're inspecting live)
- "the settings modal" → the primary component file (e.g., `src/components/Settings.tsx`)
- "this page" → the URL or the page's source file
Prefer the source file path over the dev-server URL when both exist; ports drift between runs (`bun dev` vs `bun preview`), file paths don't.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
2. **Compute the slug.** Run:
### Setup
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep the printed slug. It identifies this target's stream across runs. If the command exits non-zero ("no stable slug for input"), skip persistence for this run and tell the user; the trend won't update but the critique still goes ahead.
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
3. **Read the ignore list** at `.impeccable/critique/ignore.md` if it exists. Plain markdown; each non-empty, non-comment line is something the user has marked as "do not re-raise" (deferred tradeoffs, designer-intended deviations, detector false-positives the user accepts). When a finding's text matches a line here (case-insensitive substring against rule name or snippet), **drop it silently**. Do not mention it in the report. This is the ONLY input critique consumes from prior runs; anchoring on prior findings would defeat the point of independent assessment.
### Assessment Orchestration
### Gather Assessments
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Launch two independent assessments. **Neither may see the other's output.** This isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
Codex sub-agent gate:
- If `spawn_agent` is exposed and the user explicitly allowed sub-agents, delegation, or parallel agent work, spawn A and B immediately.
- If `spawn_agent` is exposed but the user did not explicitly allow sub-agents, ask exactly once: "Impeccable critique is designed to run two independent sub-agents for an unanchored assessment. May I use sub-agents for this critique?" Then stop until the user answers.
- If allowed, spawn A and B. If declined, run sequentially and report `Assessment independence: degraded (sub-agents declined by user)`.
- If `spawn_agent` is not exposed, do not ask; run sequentially and report `Assessment independence: degraded (spawn_agent unavailable in this session)`.
- If spawning fails after permission, run sequentially and report `Assessment independence: degraded (sub-agent spawn failed: <exact error>)`.
Prefer `fork_context: false` with self-contained prompts containing cwd, target, live URL, references, product context, and output contract. If using `fork_context: true`, omit `agent_type`, `model`, and `reasoning_effort`.
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
### Assessment A: Design Review
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
#### Assessment A: LLM Design Review
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult [cognitive-load](cognitive-load.md); report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult [heuristics-scoring](heuristics-scoring.md); score all 10 heuristics 0-4.
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
### Assessment B: Detector + Browser Evidence
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
#### Assessment B: Automated Detection
Run the bundled deterministic detector, which flags 27 specific patterns (AI slop tells + general design quality).
**CLI scan**:
CLI scan:
```bash
npx impeccable detect --json [--fast] [target]
node .agents/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization**: **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .agents/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Codex Browser note: Use the Browser skill. Do not spend a Browser attempt on `file://`. Only call `visibility.set(true)` after mutable script injection is confirmed for the `[Human]` overlay path; verify with `get()`. Use `tab.dev.logs({ filter: "impeccable" })` for console results. Its Playwright `evaluate(...)` surface is read-only; do not rely on it for mutation.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
Codex failure accounting: final Run Notes must include target slug, ignore list, assessment independence, CLI detector, browser visibility, overlay injection, live-server cleanup, temp-file cleanup, and any fallback signal used. Do not run repo status checks, late API spelunking, or unrelated verification after the report is assembled.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Codex final-answer note: `$impeccable critique` produces a report artifact, so the final chat response should intentionally exceed the usual concise close-out style. Do not title the final response "Critique Summary" unless the user explicitly asked for a summary.
Structure your feedback as a design director would:
#### Design Health Score
@@ -135,7 +122,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -174,6 +161,11 @@ Provocative questions that might unlock better solutions:
- "Does this need to feel this complex?"
- "What would a confident version of this look like?"
#### Run Notes
Keep this compact. Include status for target slug, ignore list, assessment independence, CLI detector, browser visibility, overlay injection, live server cleanup, and temp-file cleanup. For failed or skipped steps, give the concrete observed reason and the fallback signal used. In the final chat response, also include snapshot write and trend read status after persistence has run.
Codex Run Notes are final-chat only. Do not include this section in the persisted snapshot body, because persistence, trend read, and temp cleanup happen after the snapshot write and would otherwise archive stale status such as "pending after persistence."
**Remember**:
- Be direct. Vague feedback wastes everyone's time.
- Be specific. "The submit button," not "some elements."
@@ -188,7 +180,9 @@ Once the report above is finalized, write it to `.impeccable/critique/` so the u
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full report (heuristic table, anti-patterns verdict, priority issues, persona red flags) but stop before the "Ask the User" / "Recommended Actions" sections that come later.
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
Codex: exclude Run Notes from the temp body file; Run Notes are final-chat only because persistence, trend read, and temp cleanup happen after the snapshot write.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
@@ -197,13 +191,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
```
The helper prints the absolute path it wrote.
3. **Read the trend** for context:
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
4. **Append a single line to the user-visible output**, after the report and before the questions:
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
@@ -232,6 +228,8 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
- Offer concrete options, not open-ended prompts.
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
Codex final-question gate: The user-visible response must either include the targeted questions or explicitly say `Questions skipped: <reason>` because the findings were straightforward. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions.
### Recommended Actions
**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Ask the User.
@@ -27,7 +27,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -222,10 +222,21 @@ function main(argv) {
}
}
// Why pathToFileURL: on Windows, import.meta.url is file:///D:/... (forward
// slashes) while process.argv[1] is D:\... (backslashes), so the naive
// `file://${process.argv[1]}` compare fails and main() never runs — the
// script silently exits 0. pathToFileURL normalizes both. (issue #155)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
function isMainModule() {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
} catch {
// pathToFileURL normalizes Windows paths; keep it as a fallback for any
// environment where realpath is unavailable.
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
// Why the realpath check: generated skills are often reached through symlinked
// harness directories (for example a demo repo's `.agents` -> source `.agents`).
// Node resolves import.meta.url to the real file, while process.argv[1] keeps
// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
if (isMainModule()) {
main(process.argv.slice(2));
}
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find(p => fs.existsSync(p));
if (!detectorPath) {
process.stderr.write('Error: bundled detector not found.\n');
process.exit(1);
}
const { detectCli } = await import(pathToFileURL(detectorPath));
await detectCli();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import path from 'node:path';
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 {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
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}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling
// ---------------------------------------------------------------------------
async function handleStdin() {
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)) {
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
}
} catch { /* not JSON */ }
return detectText(input, '<stdin>');
}
// ---------------------------------------------------------------------------
// 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:
--fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
--json Output results as JSON
--help Show this help message
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)
--fast Forces regex for all files
Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --fast --json .`);
}
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 helpMode = args.includes('--help');
const fastMode = args.includes('--fast');
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();
} else {
const paths = targets.length > 0 ? targets : [process.cwd()];
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (/^https?:\/\//i.test(target)) {
try {
const scanner = browserDetector
? (url) => browserDetector.detectUrl(url)
: (url) => detectUrl(url);
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 mode to avoid polluting output)
if (!jsonMode) {
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);
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) {
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` +
`Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\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();
let fileFindings;
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
fileFindings = await detectHtml(file);
} else {
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
}
// 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()) {
const ext = path.extname(resolved).toLowerCase();
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved));
} else {
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
}
}
}
} finally {
if (browserDetector) await browserDetector.close();
}
}
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
#!/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 { 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,251 @@
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';
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());
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
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
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 }))
);
});
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 => finding(f.id, url, f.snippet));
}
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,420 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.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);
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
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)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black background ---
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
test: () => true,
fmt: (m) => 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 pure black background ---
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
test: () => true,
fmt: (m) => m[0] },
// --- 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*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ 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()}`;
} },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
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)}%)`)];
},
// Everything centered (regex)
(content, filePath) => {
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
}
}
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (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;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// 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', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
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',
}));
}
// 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',
}));
}
// 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 (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'everything-centered',
'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)));
}
}
return deduped;
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
detectText,
};
@@ -0,0 +1,954 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementColors,
checkElementGlow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementQuality,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { detectText } 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;
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ 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: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
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 (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('layout-rules', () => checkPageLayout(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'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
return findings;
}
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,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -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', '.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,
};
@@ -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,278 @@
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',
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',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
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: 'nested-cards',
category: 'slop',
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',
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: 'everything-centered',
category: 'slop',
name: 'Everything centered',
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
skillSection: 'Layout & Space',
skillGuideline: 'Center everything',
},
{
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: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
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',
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',
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',
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',
},
// ── Quality: general design and accessibility issues ──
{
id: 'pure-black-white',
category: 'quality',
name: 'Pure black background',
description:
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
skillSection: 'Color & Contrast',
skillGuideline: 'pure black (#000)',
},
{
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',
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',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
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',
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',
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: 'justified-text',
category: 'quality',
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',
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: 'all-caps-body',
category: 'quality',
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',
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.',
},
];
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);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
};
File diff suppressed because it is too large Load Diff
@@ -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 0255 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 ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 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,
};
@@ -0,0 +1,101 @@
// ─── 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',
'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);
// 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,
KNOWN_SERIF_FONTS,
};
@@ -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 };
@@ -155,10 +155,12 @@ function broadcast(msg) {
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (cli/engine/detect-antipatterns-browser.js).
// Detection script: prefer the skill-bundled detector, then fall back to
// source/npm package locations for local development and older installs.
// This one IS cached — detect.js rarely changes during a session.
const detectPaths = [
path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
];
+56 -81
View File
@@ -1,109 +1,82 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Setup: Resolve Target and Load Ignore List
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Before gathering assessments, do two small bookkeeping steps. They cost almost nothing and they're what makes critique iterative across runs.
### Hard Invariants
1. **Resolve the primary artifact.** The user's phrasing ("the homepage", "the pricing flow") is not stable enough to track across runs. Resolve it to a concrete file path or URL: the same one you'd already need to scan code or open in a browser. Examples:
- "the homepage" → `site/pages/index.astro` (or `http://localhost:3000/` if you're inspecting live)
- "the settings modal" → the primary component file (e.g., `src/components/Settings.tsx`)
- "this page" → the URL or the page's source file
Prefer the source file path over the dev-server URL when both exist; ports drift between runs (`bun dev` vs `bun preview`), file paths don't.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
2. **Compute the slug.** Run:
### Setup
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep the printed slug. It identifies this target's stream across runs. If the command exits non-zero ("no stable slug for input"), skip persistence for this run and tell the user; the trend won't update but the critique still goes ahead.
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
3. **Read the ignore list** at `.impeccable/critique/ignore.md` if it exists. Plain markdown; each non-empty, non-comment line is something the user has marked as "do not re-raise" (deferred tradeoffs, designer-intended deviations, detector false-positives the user accepts). When a finding's text matches a line here (case-insensitive substring against rule name or snippet), **drop it silently**. Do not mention it in the report. This is the ONLY input critique consumes from prior runs; anchoring on prior findings would defeat the point of independent assessment.
### Assessment Orchestration
### Gather Assessments
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Launch two independent assessments. **Neither may see the other's output.** This isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
### Assessment A: Design Review
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult [cognitive-load](cognitive-load.md); report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult [heuristics-scoring](heuristics-scoring.md); score all 10 heuristics 0-4.
#### Assessment A: LLM Design Review
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
### Assessment B: Detector + Browser Evidence
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
#### Assessment B: Automated Detection
Run the bundled deterministic detector, which flags 27 specific patterns (AI slop tells + general design quality).
**CLI scan**:
CLI scan:
```bash
npx impeccable detect --json [--fast] [target]
node .claude/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization**: **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .claude/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Structure your feedback as a design director would:
#### Design Health Score
@@ -135,7 +108,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -188,7 +161,7 @@ Once the report above is finalized, write it to `.impeccable/critique/` so the u
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full report (heuristic table, anti-patterns verdict, priority issues, persona red flags) but stop before the "Ask the User" / "Recommended Actions" sections that come later.
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
@@ -197,13 +170,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
```
The helper prints the absolute path it wrote.
3. **Read the trend** for context:
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
4. **Append a single line to the user-visible output**, after the report and before the questions:
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
@@ -27,7 +27,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -222,10 +222,21 @@ function main(argv) {
}
}
// Why pathToFileURL: on Windows, import.meta.url is file:///D:/... (forward
// slashes) while process.argv[1] is D:\... (backslashes), so the naive
// `file://${process.argv[1]}` compare fails and main() never runs — the
// script silently exits 0. pathToFileURL normalizes both. (issue #155)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
function isMainModule() {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
} catch {
// pathToFileURL normalizes Windows paths; keep it as a fallback for any
// environment where realpath is unavailable.
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
// Why the realpath check: generated skills are often reached through symlinked
// harness directories (for example a demo repo's `.agents` -> source `.agents`).
// Node resolves import.meta.url to the real file, while process.argv[1] keeps
// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
if (isMainModule()) {
main(process.argv.slice(2));
}
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find(p => fs.existsSync(p));
if (!detectorPath) {
process.stderr.write('Error: bundled detector not found.\n');
process.exit(1);
}
const { detectCli } = await import(pathToFileURL(detectorPath));
await detectCli();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import path from 'node:path';
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 {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
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}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling
// ---------------------------------------------------------------------------
async function handleStdin() {
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)) {
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
}
} catch { /* not JSON */ }
return detectText(input, '<stdin>');
}
// ---------------------------------------------------------------------------
// 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:
--fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
--json Output results as JSON
--help Show this help message
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)
--fast Forces regex for all files
Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --fast --json .`);
}
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 helpMode = args.includes('--help');
const fastMode = args.includes('--fast');
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();
} else {
const paths = targets.length > 0 ? targets : [process.cwd()];
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (/^https?:\/\//i.test(target)) {
try {
const scanner = browserDetector
? (url) => browserDetector.detectUrl(url)
: (url) => detectUrl(url);
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 mode to avoid polluting output)
if (!jsonMode) {
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);
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) {
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` +
`Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\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();
let fileFindings;
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
fileFindings = await detectHtml(file);
} else {
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
}
// 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()) {
const ext = path.extname(resolved).toLowerCase();
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved));
} else {
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
}
}
}
} finally {
if (browserDetector) await browserDetector.close();
}
}
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
#!/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 { 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,251 @@
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';
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());
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
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
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 }))
);
});
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 => finding(f.id, url, f.snippet));
}
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,420 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.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);
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
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)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black background ---
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
test: () => true,
fmt: (m) => 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 pure black background ---
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
test: () => true,
fmt: (m) => m[0] },
// --- 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*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ 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()}`;
} },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
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)}%)`)];
},
// Everything centered (regex)
(content, filePath) => {
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
}
}
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (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;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// 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', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
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',
}));
}
// 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',
}));
}
// 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 (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'everything-centered',
'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)));
}
}
return deduped;
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
detectText,
};
@@ -0,0 +1,954 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementColors,
checkElementGlow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementQuality,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { detectText } 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;
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ 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: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
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 (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('layout-rules', () => checkPageLayout(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'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
return findings;
}
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,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -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', '.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,
};
@@ -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,278 @@
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',
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',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
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: 'nested-cards',
category: 'slop',
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',
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: 'everything-centered',
category: 'slop',
name: 'Everything centered',
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
skillSection: 'Layout & Space',
skillGuideline: 'Center everything',
},
{
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: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
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',
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',
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',
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',
},
// ── Quality: general design and accessibility issues ──
{
id: 'pure-black-white',
category: 'quality',
name: 'Pure black background',
description:
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
skillSection: 'Color & Contrast',
skillGuideline: 'pure black (#000)',
},
{
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',
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',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
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',
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',
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: 'justified-text',
category: 'quality',
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',
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: 'all-caps-body',
category: 'quality',
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',
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.',
},
];
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);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
};
File diff suppressed because it is too large Load Diff
@@ -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 0255 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 ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 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,
};
@@ -0,0 +1,101 @@
// ─── 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',
'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);
// 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,
KNOWN_SERIF_FONTS,
};
@@ -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 };
@@ -155,10 +155,12 @@ function broadcast(msg) {
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (cli/engine/detect-antipatterns-browser.js).
// Detection script: prefer the skill-bundled detector, then fall back to
// source/npm package locations for local development and older installs.
// This one IS cached — detect.js rarely changes during a session.
const detectPaths = [
path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
];
+56 -81
View File
@@ -1,109 +1,82 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Setup: Resolve Target and Load Ignore List
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Before gathering assessments, do two small bookkeeping steps. They cost almost nothing and they're what makes critique iterative across runs.
### Hard Invariants
1. **Resolve the primary artifact.** The user's phrasing ("the homepage", "the pricing flow") is not stable enough to track across runs. Resolve it to a concrete file path or URL: the same one you'd already need to scan code or open in a browser. Examples:
- "the homepage" → `site/pages/index.astro` (or `http://localhost:3000/` if you're inspecting live)
- "the settings modal" → the primary component file (e.g., `src/components/Settings.tsx`)
- "this page" → the URL or the page's source file
Prefer the source file path over the dev-server URL when both exist; ports drift between runs (`bun dev` vs `bun preview`), file paths don't.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
2. **Compute the slug.** Run:
### Setup
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .cursor/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep the printed slug. It identifies this target's stream across runs. If the command exits non-zero ("no stable slug for input"), skip persistence for this run and tell the user; the trend won't update but the critique still goes ahead.
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
3. **Read the ignore list** at `.impeccable/critique/ignore.md` if it exists. Plain markdown; each non-empty, non-comment line is something the user has marked as "do not re-raise" (deferred tradeoffs, designer-intended deviations, detector false-positives the user accepts). When a finding's text matches a line here (case-insensitive substring against rule name or snippet), **drop it silently**. Do not mention it in the report. This is the ONLY input critique consumes from prior runs; anchoring on prior findings would defeat the point of independent assessment.
### Assessment Orchestration
### Gather Assessments
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Launch two independent assessments. **Neither may see the other's output.** This isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
### Assessment A: Design Review
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult [cognitive-load](cognitive-load.md); report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult [heuristics-scoring](heuristics-scoring.md); score all 10 heuristics 0-4.
#### Assessment A: LLM Design Review
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
### Assessment B: Detector + Browser Evidence
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
#### Assessment B: Automated Detection
Run the bundled deterministic detector, which flags 27 specific patterns (AI slop tells + general design quality).
**CLI scan**:
CLI scan:
```bash
npx impeccable detect --json [--fast] [target]
node .cursor/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization**: **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .cursor/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Structure your feedback as a design director would:
#### Design Health Score
@@ -135,7 +108,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -188,7 +161,7 @@ Once the report above is finalized, write it to `.impeccable/critique/` so the u
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full report (heuristic table, anti-patterns verdict, priority issues, persona red flags) but stop before the "Ask the User" / "Recommended Actions" sections that come later.
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
@@ -197,13 +170,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
```
The helper prints the absolute path it wrote.
3. **Read the trend** for context:
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .cursor/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
4. **Append a single line to the user-visible output**, after the report and before the questions:
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
@@ -27,7 +27,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -222,10 +222,21 @@ function main(argv) {
}
}
// Why pathToFileURL: on Windows, import.meta.url is file:///D:/... (forward
// slashes) while process.argv[1] is D:\... (backslashes), so the naive
// `file://${process.argv[1]}` compare fails and main() never runs — the
// script silently exits 0. pathToFileURL normalizes both. (issue #155)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
function isMainModule() {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
} catch {
// pathToFileURL normalizes Windows paths; keep it as a fallback for any
// environment where realpath is unavailable.
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
// Why the realpath check: generated skills are often reached through symlinked
// harness directories (for example a demo repo's `.agents` -> source `.agents`).
// Node resolves import.meta.url to the real file, while process.argv[1] keeps
// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
if (isMainModule()) {
main(process.argv.slice(2));
}
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find(p => fs.existsSync(p));
if (!detectorPath) {
process.stderr.write('Error: bundled detector not found.\n');
process.exit(1);
}
const { detectCli } = await import(pathToFileURL(detectorPath));
await detectCli();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import path from 'node:path';
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 {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
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}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling
// ---------------------------------------------------------------------------
async function handleStdin() {
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)) {
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
}
} catch { /* not JSON */ }
return detectText(input, '<stdin>');
}
// ---------------------------------------------------------------------------
// 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:
--fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
--json Output results as JSON
--help Show this help message
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)
--fast Forces regex for all files
Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --fast --json .`);
}
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 helpMode = args.includes('--help');
const fastMode = args.includes('--fast');
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();
} else {
const paths = targets.length > 0 ? targets : [process.cwd()];
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (/^https?:\/\//i.test(target)) {
try {
const scanner = browserDetector
? (url) => browserDetector.detectUrl(url)
: (url) => detectUrl(url);
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 mode to avoid polluting output)
if (!jsonMode) {
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);
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) {
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` +
`Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\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();
let fileFindings;
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
fileFindings = await detectHtml(file);
} else {
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
}
// 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()) {
const ext = path.extname(resolved).toLowerCase();
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved));
} else {
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
}
}
}
} finally {
if (browserDetector) await browserDetector.close();
}
}
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
#!/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 { 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,251 @@
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';
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());
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
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
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 }))
);
});
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 => finding(f.id, url, f.snippet));
}
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,420 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.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);
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
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)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black background ---
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
test: () => true,
fmt: (m) => 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 pure black background ---
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
test: () => true,
fmt: (m) => m[0] },
// --- 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*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ 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()}`;
} },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
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)}%)`)];
},
// Everything centered (regex)
(content, filePath) => {
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
}
}
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (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;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// 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', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
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',
}));
}
// 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',
}));
}
// 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 (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'everything-centered',
'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)));
}
}
return deduped;
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
detectText,
};
@@ -0,0 +1,954 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementColors,
checkElementGlow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementQuality,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { detectText } 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;
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ 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: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
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 (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('layout-rules', () => checkPageLayout(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'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
return findings;
}
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,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -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', '.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,
};
@@ -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,278 @@
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',
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',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
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: 'nested-cards',
category: 'slop',
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',
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: 'everything-centered',
category: 'slop',
name: 'Everything centered',
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
skillSection: 'Layout & Space',
skillGuideline: 'Center everything',
},
{
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: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
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',
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',
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',
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',
},
// ── Quality: general design and accessibility issues ──
{
id: 'pure-black-white',
category: 'quality',
name: 'Pure black background',
description:
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
skillSection: 'Color & Contrast',
skillGuideline: 'pure black (#000)',
},
{
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',
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',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
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',
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',
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: 'justified-text',
category: 'quality',
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',
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: 'all-caps-body',
category: 'quality',
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',
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.',
},
];
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);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
};
File diff suppressed because it is too large Load Diff
@@ -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 0255 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 ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 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,
};
@@ -0,0 +1,101 @@
// ─── 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',
'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);
// 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,
KNOWN_SERIF_FONTS,
};
@@ -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 };
@@ -155,10 +155,12 @@ function broadcast(msg) {
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (cli/engine/detect-antipatterns-browser.js).
// Detection script: prefer the skill-bundled detector, then fall back to
// source/npm package locations for local development and older installs.
// This one IS cached — detect.js rarely changes during a session.
const detectPaths = [
path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
];
+56 -81
View File
@@ -1,109 +1,82 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Setup: Resolve Target and Load Ignore List
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Before gathering assessments, do two small bookkeeping steps. They cost almost nothing and they're what makes critique iterative across runs.
### Hard Invariants
1. **Resolve the primary artifact.** The user's phrasing ("the homepage", "the pricing flow") is not stable enough to track across runs. Resolve it to a concrete file path or URL: the same one you'd already need to scan code or open in a browser. Examples:
- "the homepage" → `site/pages/index.astro` (or `http://localhost:3000/` if you're inspecting live)
- "the settings modal" → the primary component file (e.g., `src/components/Settings.tsx`)
- "this page" → the URL or the page's source file
Prefer the source file path over the dev-server URL when both exist; ports drift between runs (`bun dev` vs `bun preview`), file paths don't.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
2. **Compute the slug.** Run:
### Setup
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .gemini/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep the printed slug. It identifies this target's stream across runs. If the command exits non-zero ("no stable slug for input"), skip persistence for this run and tell the user; the trend won't update but the critique still goes ahead.
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
3. **Read the ignore list** at `.impeccable/critique/ignore.md` if it exists. Plain markdown; each non-empty, non-comment line is something the user has marked as "do not re-raise" (deferred tradeoffs, designer-intended deviations, detector false-positives the user accepts). When a finding's text matches a line here (case-insensitive substring against rule name or snippet), **drop it silently**. Do not mention it in the report. This is the ONLY input critique consumes from prior runs; anchoring on prior findings would defeat the point of independent assessment.
### Assessment Orchestration
### Gather Assessments
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Launch two independent assessments. **Neither may see the other's output.** This isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
### Assessment A: Design Review
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult [cognitive-load](cognitive-load.md); report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult [heuristics-scoring](heuristics-scoring.md); score all 10 heuristics 0-4.
#### Assessment A: LLM Design Review
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
### Assessment B: Detector + Browser Evidence
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
#### Assessment B: Automated Detection
Run the bundled deterministic detector, which flags 27 specific patterns (AI slop tells + general design quality).
**CLI scan**:
CLI scan:
```bash
npx impeccable detect --json [--fast] [target]
node .gemini/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization**: **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .gemini/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Structure your feedback as a design director would:
#### Design Health Score
@@ -135,7 +108,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -188,7 +161,7 @@ Once the report above is finalized, write it to `.impeccable/critique/` so the u
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full report (heuristic table, anti-patterns verdict, priority issues, persona red flags) but stop before the "Ask the User" / "Recommended Actions" sections that come later.
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
@@ -197,13 +170,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
```
The helper prints the absolute path it wrote.
3. **Read the trend** for context:
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .gemini/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
4. **Append a single line to the user-visible output**, after the report and before the questions:
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
@@ -27,7 +27,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -222,10 +222,21 @@ function main(argv) {
}
}
// Why pathToFileURL: on Windows, import.meta.url is file:///D:/... (forward
// slashes) while process.argv[1] is D:\... (backslashes), so the naive
// `file://${process.argv[1]}` compare fails and main() never runs — the
// script silently exits 0. pathToFileURL normalizes both. (issue #155)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
function isMainModule() {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
} catch {
// pathToFileURL normalizes Windows paths; keep it as a fallback for any
// environment where realpath is unavailable.
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
// Why the realpath check: generated skills are often reached through symlinked
// harness directories (for example a demo repo's `.agents` -> source `.agents`).
// Node resolves import.meta.url to the real file, while process.argv[1] keeps
// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
if (isMainModule()) {
main(process.argv.slice(2));
}
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find(p => fs.existsSync(p));
if (!detectorPath) {
process.stderr.write('Error: bundled detector not found.\n');
process.exit(1);
}
const { detectCli } = await import(pathToFileURL(detectorPath));
await detectCli();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import path from 'node:path';
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 {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
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}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling
// ---------------------------------------------------------------------------
async function handleStdin() {
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)) {
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
}
} catch { /* not JSON */ }
return detectText(input, '<stdin>');
}
// ---------------------------------------------------------------------------
// 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:
--fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
--json Output results as JSON
--help Show this help message
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)
--fast Forces regex for all files
Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --fast --json .`);
}
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 helpMode = args.includes('--help');
const fastMode = args.includes('--fast');
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();
} else {
const paths = targets.length > 0 ? targets : [process.cwd()];
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (/^https?:\/\//i.test(target)) {
try {
const scanner = browserDetector
? (url) => browserDetector.detectUrl(url)
: (url) => detectUrl(url);
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 mode to avoid polluting output)
if (!jsonMode) {
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);
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) {
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` +
`Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\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();
let fileFindings;
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
fileFindings = await detectHtml(file);
} else {
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
}
// 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()) {
const ext = path.extname(resolved).toLowerCase();
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved));
} else {
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
}
}
}
} finally {
if (browserDetector) await browserDetector.close();
}
}
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
#!/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 { 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,251 @@
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';
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());
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
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
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 }))
);
});
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 => finding(f.id, url, f.snippet));
}
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,420 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.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);
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
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)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black background ---
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
test: () => true,
fmt: (m) => 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 pure black background ---
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
test: () => true,
fmt: (m) => m[0] },
// --- 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*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ 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()}`;
} },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
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)}%)`)];
},
// Everything centered (regex)
(content, filePath) => {
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
}
}
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (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;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// 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', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
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',
}));
}
// 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',
}));
}
// 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 (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'everything-centered',
'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)));
}
}
return deduped;
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
detectText,
};
@@ -0,0 +1,954 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementColors,
checkElementGlow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementQuality,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { detectText } 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;
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ 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: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
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 (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('layout-rules', () => checkPageLayout(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'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
return findings;
}
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,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -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', '.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,
};
@@ -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,278 @@
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',
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',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
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: 'nested-cards',
category: 'slop',
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',
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: 'everything-centered',
category: 'slop',
name: 'Everything centered',
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
skillSection: 'Layout & Space',
skillGuideline: 'Center everything',
},
{
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: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
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',
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',
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',
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',
},
// ── Quality: general design and accessibility issues ──
{
id: 'pure-black-white',
category: 'quality',
name: 'Pure black background',
description:
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
skillSection: 'Color & Contrast',
skillGuideline: 'pure black (#000)',
},
{
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',
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',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
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',
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',
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: 'justified-text',
category: 'quality',
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',
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: 'all-caps-body',
category: 'quality',
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',
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.',
},
];
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);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
};
File diff suppressed because it is too large Load Diff
@@ -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 0255 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 ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 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,
};
@@ -0,0 +1,101 @@
// ─── 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',
'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);
// 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,
KNOWN_SERIF_FONTS,
};
@@ -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 };
@@ -155,10 +155,12 @@ function broadcast(msg) {
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (cli/engine/detect-antipatterns-browser.js).
// Detection script: prefer the skill-bundled detector, then fall back to
// source/npm package locations for local development and older installs.
// This one IS cached — detect.js rarely changes during a session.
const detectPaths = [
path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
];
+56 -81
View File
@@ -1,109 +1,82 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Setup: Resolve Target and Load Ignore List
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Before gathering assessments, do two small bookkeeping steps. They cost almost nothing and they're what makes critique iterative across runs.
### Hard Invariants
1. **Resolve the primary artifact.** The user's phrasing ("the homepage", "the pricing flow") is not stable enough to track across runs. Resolve it to a concrete file path or URL: the same one you'd already need to scan code or open in a browser. Examples:
- "the homepage" → `site/pages/index.astro` (or `http://localhost:3000/` if you're inspecting live)
- "the settings modal" → the primary component file (e.g., `src/components/Settings.tsx`)
- "this page" → the URL or the page's source file
Prefer the source file path over the dev-server URL when both exist; ports drift between runs (`bun dev` vs `bun preview`), file paths don't.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
2. **Compute the slug.** Run:
### Setup
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .github/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep the printed slug. It identifies this target's stream across runs. If the command exits non-zero ("no stable slug for input"), skip persistence for this run and tell the user; the trend won't update but the critique still goes ahead.
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
3. **Read the ignore list** at `.impeccable/critique/ignore.md` if it exists. Plain markdown; each non-empty, non-comment line is something the user has marked as "do not re-raise" (deferred tradeoffs, designer-intended deviations, detector false-positives the user accepts). When a finding's text matches a line here (case-insensitive substring against rule name or snippet), **drop it silently**. Do not mention it in the report. This is the ONLY input critique consumes from prior runs; anchoring on prior findings would defeat the point of independent assessment.
### Assessment Orchestration
### Gather Assessments
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Launch two independent assessments. **Neither may see the other's output.** This isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
### Assessment A: Design Review
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult [cognitive-load](cognitive-load.md); report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult [heuristics-scoring](heuristics-scoring.md); score all 10 heuristics 0-4.
#### Assessment A: LLM Design Review
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
### Assessment B: Detector + Browser Evidence
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
#### Assessment B: Automated Detection
Run the bundled deterministic detector, which flags 27 specific patterns (AI slop tells + general design quality).
**CLI scan**:
CLI scan:
```bash
npx impeccable detect --json [--fast] [target]
node .github/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization**: **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .github/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Structure your feedback as a design director would:
#### Design Health Score
@@ -135,7 +108,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -188,7 +161,7 @@ Once the report above is finalized, write it to `.impeccable/critique/` so the u
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full report (heuristic table, anti-patterns verdict, priority issues, persona red flags) but stop before the "Ask the User" / "Recommended Actions" sections that come later.
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
@@ -197,13 +170,15 @@ Skip this step if the Setup slug was null (vague or root-level target).
```
The helper prints the absolute path it wrote.
3. **Read the trend** for context:
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .github/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
4. **Append a single line to the user-visible output**, after the report and before the questions:
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
@@ -27,7 +27,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -222,10 +222,21 @@ function main(argv) {
}
}
// Why pathToFileURL: on Windows, import.meta.url is file:///D:/... (forward
// slashes) while process.argv[1] is D:\... (backslashes), so the naive
// `file://${process.argv[1]}` compare fails and main() never runs — the
// script silently exits 0. pathToFileURL normalizes both. (issue #155)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
function isMainModule() {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
} catch {
// pathToFileURL normalizes Windows paths; keep it as a fallback for any
// environment where realpath is unavailable.
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
// Why the realpath check: generated skills are often reached through symlinked
// harness directories (for example a demo repo's `.agents` -> source `.agents`).
// Node resolves import.meta.url to the real file, while process.argv[1] keeps
// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
if (isMainModule()) {
main(process.argv.slice(2));
}
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find(p => fs.existsSync(p));
if (!detectorPath) {
process.stderr.write('Error: bundled detector not found.\n');
process.exit(1);
}
const { detectCli } = await import(pathToFileURL(detectorPath));
await detectCli();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import path from 'node:path';
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 {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
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}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling
// ---------------------------------------------------------------------------
async function handleStdin() {
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)) {
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
}
} catch { /* not JSON */ }
return detectText(input, '<stdin>');
}
// ---------------------------------------------------------------------------
// 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:
--fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
--json Output results as JSON
--help Show this help message
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)
--fast Forces regex for all files
Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --fast --json .`);
}
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 helpMode = args.includes('--help');
const fastMode = args.includes('--fast');
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();
} else {
const paths = targets.length > 0 ? targets : [process.cwd()];
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (/^https?:\/\//i.test(target)) {
try {
const scanner = browserDetector
? (url) => browserDetector.detectUrl(url)
: (url) => detectUrl(url);
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 mode to avoid polluting output)
if (!jsonMode) {
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);
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) {
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` +
`Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\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();
let fileFindings;
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
fileFindings = await detectHtml(file);
} else {
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
}
// 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()) {
const ext = path.extname(resolved).toLowerCase();
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved));
} else {
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
}
}
}
} finally {
if (browserDetector) await browserDetector.close();
}
}
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
#!/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 { 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,251 @@
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';
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());
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
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
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 }))
);
});
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 => finding(f.id, url, f.snippet));
}
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,420 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.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);
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
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)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black background ---
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
test: () => true,
fmt: (m) => 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 pure black background ---
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
test: () => true,
fmt: (m) => m[0] },
// --- 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*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ 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()}`;
} },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
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)}%)`)];
},
// Everything centered (regex)
(content, filePath) => {
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
}
}
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (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;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// 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', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
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',
}));
}
// 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',
}));
}
// 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 (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'everything-centered',
'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)));
}
}
return deduped;
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
detectText,
};
@@ -0,0 +1,954 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementColors,
checkElementGlow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementQuality,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { detectText } 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;
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ 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: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
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 (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('layout-rules', () => checkPageLayout(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'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
return findings;
}
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,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -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', '.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,
};
@@ -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,278 @@
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',
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',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
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: 'nested-cards',
category: 'slop',
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',
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: 'everything-centered',
category: 'slop',
name: 'Everything centered',
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
skillSection: 'Layout & Space',
skillGuideline: 'Center everything',
},
{
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: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
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',
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',
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',
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',
},
// ── Quality: general design and accessibility issues ──
{
id: 'pure-black-white',
category: 'quality',
name: 'Pure black background',
description:
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
skillSection: 'Color & Contrast',
skillGuideline: 'pure black (#000)',
},
{
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',
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',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
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',
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',
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: 'justified-text',
category: 'quality',
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',
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: 'all-caps-body',
category: 'quality',
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',
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.',
},
];
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);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
};

Some files were not shown because too many files have changed in this diff Show More