chore: update vendored skills and record provenance
humanizer 2.2.0 -> 2.9.1 (blader/humanizer, MIT): adds a Voice Calibration section and a passive-voice pattern, reworks negative parallelisms and em dashes. Version moved to metadata.version upstream. impeccable 3.6.0 -> 4.0.4 (pbakaus/impeccable, Apache-2.0): the repo tags the skill and the npm CLI separately, so 3.6.0 was a real release and npm's 3.5.0 was never the comparison. Adds native-platform reference briefs. Each now carries an UPSTREAM file; bin/check-vendored.sh reports drift.
This commit is contained in:
@@ -530,7 +530,11 @@ if (IS_BROWSER) {
|
||||
function generateSelector(el) {
|
||||
if (el === document.body) return 'body';
|
||||
if (el === document.documentElement) return 'html';
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
// Read via getAttribute when `el.id` is not a string — a <form> with a
|
||||
// named control (e.g. <input name="id">) shadows the builtin getter and
|
||||
// returns the element, producing a garbage `#[object …]` selector (#407).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
||||
if (elId) return '#' + CSS.escape(elId);
|
||||
|
||||
const parts = [];
|
||||
let current = el;
|
||||
@@ -1222,8 +1226,13 @@ if (IS_BROWSER) {
|
||||
return {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: ap?.severity || 'warning',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
description: ap ? ap.description : '',
|
||||
};
|
||||
@@ -1260,20 +1269,213 @@ if (IS_BROWSER) {
|
||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
||||
}
|
||||
|
||||
const DESIGN_COLOR_TOLERANCE = 6;
|
||||
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
|
||||
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function normalizeBrowserFontName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function browserPrimaryFont(stack) {
|
||||
if (!stack || /var\(/i.test(stack)) return '';
|
||||
return String(stack || '')
|
||||
.split(',')
|
||||
.map(normalizeBrowserFontName)
|
||||
.find(font => font && !GENERIC_FONTS.has(font)) || '';
|
||||
}
|
||||
|
||||
function browserDesignSystemConfig() {
|
||||
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
|
||||
if (!raw?.present) return null;
|
||||
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
|
||||
const allowedColors = (raw.allowedColors || [])
|
||||
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
||||
.map(color => ({ r: color.r, g: color.g, b: color.b }));
|
||||
const allowedRadii = (raw.allowedRadii || [])
|
||||
.map(Number)
|
||||
.filter(px => Number.isFinite(px));
|
||||
return {
|
||||
present: true,
|
||||
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
|
||||
allowedFonts,
|
||||
hasColors: raw.hasColors === true && allowedColors.length > 0,
|
||||
allowedColors,
|
||||
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
|
||||
allowedRadii,
|
||||
hasPillRadius: raw.hasPillRadius === true,
|
||||
};
|
||||
}
|
||||
|
||||
function browserColorsClose(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Math.max(
|
||||
Math.abs(a.r - b.r),
|
||||
Math.abs(a.g - b.g),
|
||||
Math.abs(a.b - b.b),
|
||||
) <= DESIGN_COLOR_TOLERANCE;
|
||||
}
|
||||
|
||||
function isBrowserDesignColorAllowed(raw, designSystem) {
|
||||
if (!designSystem?.hasColors) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
|
||||
if (text.includes('var(')) return true;
|
||||
const parsed = parseAnyColor(text);
|
||||
if (!parsed) return true;
|
||||
if ((parsed.a ?? 1) <= 0.05) return true;
|
||||
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
|
||||
}
|
||||
|
||||
function isBrowserTransparentCss(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent') return true;
|
||||
const parsed = parseAnyColor(text);
|
||||
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
|
||||
}
|
||||
|
||||
function isBrowserDesignRadiusAllowed(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
|
||||
if (text.includes('var(') || text.includes('%')) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
|
||||
if (designSystem.hasPillRadius && px >= 99) return true;
|
||||
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
function browserRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function browserHasDirectText(el) {
|
||||
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
function browserSampleText(el) {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
function shouldSkipDesignElement(el) {
|
||||
const tag = el.tagName?.toLowerCase?.() || '';
|
||||
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
|
||||
}
|
||||
|
||||
function checkElementDesignSystemDOM(el, designSystem, seen) {
|
||||
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
|
||||
const findings = [];
|
||||
const tag = el.tagName?.toLowerCase?.() || 'unknown';
|
||||
const style = getComputedStyle(el);
|
||||
|
||||
if (designSystem.hasFonts && browserHasDirectText(el)) {
|
||||
const font = browserPrimaryFont(style.fontFamily || '');
|
||||
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
|
||||
seen.fonts.add(font);
|
||||
findings.push({
|
||||
type: 'design-system-font',
|
||||
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
|
||||
ignoreValue: font,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
const colorChecks = [];
|
||||
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
|
||||
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
|
||||
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
|
||||
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
|
||||
}
|
||||
}
|
||||
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
|
||||
|
||||
for (const [kind, raw] of colorChecks) {
|
||||
const label = String(raw || '').trim().replace(/\s+/g, ' ');
|
||||
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
|
||||
const key = `${kind}:${label}`;
|
||||
if (seen.colors.has(key)) continue;
|
||||
seen.colors.add(key);
|
||||
findings.push({
|
||||
type: 'design-system-color',
|
||||
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
|
||||
ignoreValue: label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
for (const token of browserRadiusTokens(style.borderRadius || '')) {
|
||||
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
|
||||
if (seen.radii.has(token)) continue;
|
||||
seen.radii.add(token);
|
||||
findings.push({
|
||||
type: 'design-system-radius',
|
||||
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
|
||||
ignoreValue: token,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function decodeBrowserGoogleFamily(value) {
|
||||
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
|
||||
try {
|
||||
return decodeURIComponent(family);
|
||||
} catch {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
function checkBrowserDesignSystemSources(designSystem, seen) {
|
||||
if (!designSystem?.hasFonts) return [];
|
||||
const findings = [];
|
||||
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
|
||||
const href = link.getAttribute('href') || '';
|
||||
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
|
||||
const display = decodeBrowserGoogleFamily(match[1]);
|
||||
const font = normalizeBrowserFontName(display);
|
||||
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
|
||||
seen.fonts.add(font);
|
||||
findings.push({
|
||||
type: 'design-system-font',
|
||||
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
|
||||
ignoreValue: display,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function collectBrowserFindings() {
|
||||
const groupMap = new Map();
|
||||
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
||||
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
||||
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
|
||||
// real browser env (detector page, live overlay, extension) running every
|
||||
// check is free, so we always surface them; the gating is purely a CLI
|
||||
// output concern, applied in the Node engines' detect* return paths.
|
||||
const designSystem = browserDesignSystemConfig();
|
||||
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
|
||||
// All deterministic rules run in the browser and extension path.
|
||||
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
// Skip browser extension elements (Claude, etc.)
|
||||
const elId = el.id || '';
|
||||
// Skip browser extension elements (Claude, etc.). Use getAttribute when
|
||||
// `el.id` is not a string: a <form> with a named control like
|
||||
// <input name="id"> shadows the builtin `id` getter and returns the
|
||||
// element, whose `.startsWith` throws (issue #407).
|
||||
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
||||
// Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
|
||||
// These are inspector chrome, not part of the user's design.
|
||||
@@ -1283,10 +1485,12 @@ if (IS_BROWSER) {
|
||||
|
||||
const findings = [
|
||||
...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
@@ -1294,6 +1498,8 @@ if (IS_BROWSER) {
|
||||
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
|
||||
...checkElementDesignSystemDOM(el, designSystem, designSeen),
|
||||
].filter(f => _ruleOk(f.type));
|
||||
|
||||
addBrowserFindings(groupMap, el, findings);
|
||||
@@ -1310,13 +1516,20 @@ if (IS_BROWSER) {
|
||||
|
||||
const pageLevelFindings = [];
|
||||
|
||||
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (designSourceFindings.length > 0) {
|
||||
pageLevelFindings.push(...designSourceFindings);
|
||||
addBrowserFindings(groupMap, document.body, designSourceFindings);
|
||||
}
|
||||
|
||||
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
|
||||
if (typoFindings.length > 0) {
|
||||
pageLevelFindings.push(...typoFindings);
|
||||
addBrowserFindings(groupMap, document.body, typoFindings);
|
||||
}
|
||||
|
||||
const sectionKickerFindings = checkRepeatedSectionKickersDOM()
|
||||
const sectionKickerFindings = checkKickerAboveHeadingDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (sectionKickerFindings.length > 0) {
|
||||
@@ -1324,12 +1537,66 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, sectionKickerFindings);
|
||||
}
|
||||
|
||||
const numberedLabelFindings = checkNumberedSectionLabelsDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (numberedLabelFindings.length > 0) {
|
||||
pageLevelFindings.push(...numberedLabelFindings);
|
||||
addBrowserFindings(groupMap, document.body, numberedLabelFindings);
|
||||
}
|
||||
|
||||
const repeatedTextFindings = checkRepeatedContainerTextDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (repeatedTextFindings.length > 0) {
|
||||
pageLevelFindings.push(...repeatedTextFindings);
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);
|
||||
}
|
||||
|
||||
// Heading rhythm (browser-only: needs real layout for the gap math)
|
||||
const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type));
|
||||
for (const f of headingRhythmFindings) {
|
||||
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
||||
}
|
||||
|
||||
// Edge-flush cards in horizontal scrollers (browser-only: needs real
|
||||
// layout for the scroller clip box vs card rect math)
|
||||
const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type));
|
||||
for (const f of edgeFlushFindings) {
|
||||
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
||||
}
|
||||
|
||||
// Text occlusion / element overlap (browser-only: needs real layout +
|
||||
// elementFromPoint to confirm what actually paints on top)
|
||||
const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type));
|
||||
for (const f of occlusionFindings) {
|
||||
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
||||
}
|
||||
|
||||
// First-viewport column overflow — the stretched-hero signature
|
||||
// (browser-only: needs real layout for the content-extent math)
|
||||
const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type));
|
||||
for (const f of colOverflowFindings) {
|
||||
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
||||
}
|
||||
|
||||
// Page-level quality checks (headings, etc.)
|
||||
const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
|
||||
if (qualityFindings.length > 0) {
|
||||
@@ -1355,7 +1622,25 @@ if (IS_BROWSER) {
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
} else if (f.id === 'pulsing-dot' && f.selector) {
|
||||
// The string scan promotes header/nav dots on its own; with a live
|
||||
// layout also promote dots resting in the first ~900px of the page
|
||||
// (the hero region), which the source scan cannot measure.
|
||||
try {
|
||||
const dotEl = document.querySelector(f.selector);
|
||||
if (dotEl) {
|
||||
const rect = dotEl.getBoundingClientRect();
|
||||
const pageTop = rect.top + (window.scrollY || 0);
|
||||
if (pageTop <= 900) item.severity = 'error';
|
||||
}
|
||||
} catch { /* unresolvable selector: keep registry severity */ }
|
||||
}
|
||||
return item;
|
||||
}).filter(f => _ruleOk(f.type));
|
||||
pageLevelFindings.push(...mapped);
|
||||
addBrowserFindings(groupMap, document.body, mapped);
|
||||
}
|
||||
@@ -1729,6 +2014,9 @@ if (IS_BROWSER) {
|
||||
window.impeccableDetectAsync = detectAsync;
|
||||
window.impeccableScan = scan;
|
||||
window.impeccableScanAsync = scanAsync;
|
||||
// Raw measurement for the URL engine's content-hidden-at-rest pass: it
|
||||
// drives a reveal sweep from Node and thresholds the result itself.
|
||||
window.impeccableMeasureHiddenText = measureHiddenTextDOM;
|
||||
window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
|
||||
window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
|
||||
window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
import { detectText } from '../engines/regex/detect-text.mjs';
|
||||
import {
|
||||
filterDetectionFindings,
|
||||
readDetectionConfig,
|
||||
shouldIgnoreDetectionFile,
|
||||
} from '../../lib/impeccable-config.mjs';
|
||||
import {
|
||||
HTML_EXTENSIONS,
|
||||
buildImportGraph,
|
||||
@@ -16,9 +24,41 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
@@ -33,7 +73,28 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -41,7 +102,11 @@ function formatFindings(findings, jsonMode) {
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStdin(options = {}) {
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
@@ -49,11 +114,12 @@ async function handleStdin(options = {}) {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
const options = resolve(fp);
|
||||
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
||||
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', options);
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
@@ -79,21 +145,49 @@ function printUsage() {
|
||||
Scan files or URLs for UI anti-patterns and design quality issues.
|
||||
|
||||
Options:
|
||||
--json Output results as JSON
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--help Show this help message
|
||||
--json Output results as JSON
|
||||
--quiet In text mode, only print the final findings count
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--viewport <WxH> Browser viewport for URL scans (default 1280x800),
|
||||
e.g. --viewport 390x844 for a mobile-width pass
|
||||
--no-config Do not apply project config, detector ignores, inline
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
|
||||
Inline ignores:
|
||||
In-file comments waive a finding where it lives and travel with the file:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
|
||||
// impeccable-disable-next-line bounce-easing: intentional bounce
|
||||
impeccable-disable applies to the whole file; -line / -next-line are scoped.
|
||||
List one or more rule ids (comma-separated), or omit them / use * for all.
|
||||
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
URLs Puppeteer full browser rendering (auto-detected)
|
||||
URLs Puppeteer full browser rendering (auto-detected;
|
||||
http(s):// and file:// URLs)
|
||||
|
||||
Examples:
|
||||
impeccable detect src/
|
||||
impeccable detect index.html
|
||||
impeccable detect https://example.com
|
||||
impeccable detect --json .`);
|
||||
impeccable detect --json .
|
||||
impeccable detect --no-config src/`);
|
||||
}
|
||||
|
||||
async function detectCli() {
|
||||
@@ -104,7 +198,9 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
@@ -114,10 +210,74 @@ async function detectCli() {
|
||||
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
|
||||
);
|
||||
}
|
||||
const providers = [];
|
||||
if (args.includes('--gpt')) providers.push('gpt');
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const scanOptions = { providers };
|
||||
if (args.includes('--gpt') || args.includes('--gemini')) {
|
||||
process.stderr.write(
|
||||
'Note: --gpt and --gemini are deprecated and ignored. Generated-UI tells now run by default.\n',
|
||||
);
|
||||
}
|
||||
const configEnabled = !args.includes('--no-config');
|
||||
const detectionConfig = configEnabled
|
||||
? readDetectionConfig(process.cwd())
|
||||
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
let viewport = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--viewport' && !args[i].startsWith('--viewport=')) continue;
|
||||
const inline = args[i].startsWith('--viewport=');
|
||||
const value = inline ? args[i].slice('--viewport='.length) : args[i + 1];
|
||||
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value || '');
|
||||
if (!match) {
|
||||
process.stderr.write('Error: --viewport requires a WxH value, e.g. --viewport 390x844\n');
|
||||
process.exit(1);
|
||||
}
|
||||
viewport = { width: Number(match[1]), height: Number(match[2]) };
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled };
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -125,19 +285,31 @@ async function detectCli() {
|
||||
let allFindings = [];
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
// real cascade, real computed styles, real layout. Callers that want a
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlRe = /^(?:https?|file):\/\//i;
|
||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
if (urlRe.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
: (url) => detectUrl(url, scanOptions);
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
||||
continue;
|
||||
@@ -149,8 +321,8 @@ async function detectCli() {
|
||||
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) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
if (!jsonMode && !quietMode) {
|
||||
const fwConfig = detectFrameworkConfig(resolved);
|
||||
if (fwConfig) {
|
||||
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
||||
@@ -175,11 +347,12 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved);
|
||||
const files = walkDir(resolved)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
|
||||
process.stderr.write(
|
||||
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
||||
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
|
||||
@@ -202,11 +375,14 @@ async function detectCli() {
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
let fileFindings;
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file, scanOptions);
|
||||
fileFindings = await detectHtml(file, fileOptions);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
@@ -219,11 +395,13 @@ async function detectCli() {
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
allFindings.push(...await detectHtml(resolved, fileOptions));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
||||
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,10 +410,26 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
process.exit(primary.length > 0 ? 2 : 0);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { finding } from './findings.mjs';
|
||||
import { GENERIC_FONTS } from './shared/constants.mjs';
|
||||
import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
|
||||
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// Files/dirs whose presence marks a directory as a project root. Mirrors the
|
||||
// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
|
||||
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
|
||||
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;
|
||||
const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
|
||||
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
if (fs.existsSync(abs)) return abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDesignMdPath(cwd = process.cwd()) {
|
||||
const root = firstExisting(cwd, DESIGN_NAMES);
|
||||
if (root) return { path: root, contextDir: cwd };
|
||||
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const dir = path.resolve(cwd, rel);
|
||||
const found = firstExisting(dir, DESIGN_NAMES);
|
||||
if (found) return { path: found, contextDir: dir };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
path.join(cwd, '.impeccable', 'design.json'),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
path.join(contextDir, 'DESIGN.json'),
|
||||
];
|
||||
return candidates.find((candidate, index) =>
|
||||
candidates.indexOf(candidate) === index && fs.existsSync(candidate)
|
||||
) || null;
|
||||
}
|
||||
|
||||
function parseFrontmatter(md) {
|
||||
const lines = String(md || '').split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== '---') return null;
|
||||
let end = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') { end = i; break; }
|
||||
}
|
||||
if (end === -1) return null;
|
||||
try {
|
||||
return parseYamlSubset(lines.slice(1, end).join('\n'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseYamlSubset(yaml) {
|
||||
const root = {};
|
||||
const stack = [{ indent: -1, obj: root }];
|
||||
|
||||
for (const raw of String(yaml || '').split(/\r?\n/)) {
|
||||
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
||||
const indent = raw.match(/^\s*/)[0].length;
|
||||
const content = raw.slice(indent);
|
||||
const colonIdx = findTopLevelColon(content);
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
|
||||
|
||||
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
|
||||
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
|
||||
const parent = stack[stack.length - 1].obj;
|
||||
|
||||
if (rest === '') {
|
||||
const obj = {};
|
||||
parent[key] = obj;
|
||||
stack.push({ indent, obj });
|
||||
} else {
|
||||
parent[key] = parseScalar(rest);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function findTopLevelColon(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ':') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function unquoteYamlKey(key) {
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
return key.slice(1, -1);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function stripInlineYamlComment(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
|
||||
return s.slice(0, i).trimEnd();
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === 'null' || s === '~') return null;
|
||||
if (/^-?\d+$/.test(s)) return Number(s);
|
||||
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFontName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\s*!important\s*$/i, '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function splitFontStack(stack) {
|
||||
return String(stack || '')
|
||||
.replace(/\s*!important\s*$/i, '')
|
||||
.split(',')
|
||||
.map(normalizeFontName)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function primaryFont(stack) {
|
||||
if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return '';
|
||||
return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || '';
|
||||
}
|
||||
|
||||
function isLiteralFontStack(stack) {
|
||||
const text = String(stack || '');
|
||||
return !/[$`{}]|\s\+\s|\|\|/.test(text);
|
||||
}
|
||||
|
||||
function cssColorLabel(raw) {
|
||||
return String(raw || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function colorKey(color) {
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b}`;
|
||||
}
|
||||
|
||||
function colorsClose(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Math.max(
|
||||
Math.abs(a.r - b.r),
|
||||
Math.abs(a.g - b.g),
|
||||
Math.abs(a.b - b.b),
|
||||
) <= COLOR_CHANNEL_TOLERANCE;
|
||||
}
|
||||
|
||||
function hslToRgb(H, S, L, alpha = 1) {
|
||||
const h = (((H % 360) + 360) % 360) / 360;
|
||||
const s = Math.max(0, Math.min(1, S));
|
||||
const l = Math.max(0, Math.min(1, L));
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return {
|
||||
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
g: Math.round(hue2rgb(p, q, h) * 255),
|
||||
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDesignColor(value) {
|
||||
const text = String(value || '').trim();
|
||||
const parsed = parseAnyColor(text);
|
||||
if (parsed) return parsed;
|
||||
const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i);
|
||||
if (hsl) {
|
||||
return hslToRgb(
|
||||
parseFloat(hsl[1]),
|
||||
parseFloat(hsl[2]) / 100,
|
||||
parseFloat(hsl[3]) / 100,
|
||||
hsl[4] !== undefined ? parseFloat(hsl[4]) : 1,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addDesignColor(out, value, label) {
|
||||
const parsed = parseDesignColor(value);
|
||||
if (!parsed) return;
|
||||
const key = colorKey(parsed);
|
||||
if (!out.allowedColorKeys.has(key)) {
|
||||
out.allowedColorKeys.set(key, { color: parsed, labels: [] });
|
||||
}
|
||||
out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value));
|
||||
}
|
||||
|
||||
function addColorObject(out, colors, prefix = 'colors') {
|
||||
if (!colors || typeof colors !== 'object') return;
|
||||
for (const [name, value] of Object.entries(colors)) {
|
||||
if (typeof value === 'string') {
|
||||
addDesignColor(out, value, `${prefix}.${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSidecarColors(out, sidecar) {
|
||||
const colorMeta = sidecar?.extensions?.colorMeta;
|
||||
if (!colorMeta || typeof colorMeta !== 'object') return;
|
||||
|
||||
for (const [name, meta] of Object.entries(colorMeta)) {
|
||||
if (!meta || typeof meta !== 'object') continue;
|
||||
if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`);
|
||||
if (Array.isArray(meta.tonalRamp)) {
|
||||
for (const [index, value] of meta.tonalRamp.entries()) {
|
||||
if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addTypographyFonts(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
for (const role of Object.values(typography)) {
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
if (typeof role.fontFamily !== 'string') continue;
|
||||
for (const font of splitFontStack(role.fontFamily)) {
|
||||
if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFontSizeStep(out, raw, { fluid = false } = {}) {
|
||||
const text = String(raw ?? '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return;
|
||||
out.allowedFontSizes.push({ value: text, px, fluid });
|
||||
}
|
||||
|
||||
// Split a fluid value into its three terms, or null when it is not a
|
||||
// well-formed clamp(). Used both to read DESIGN.md's fluid roles and to
|
||||
// validate fluid values in source, so the two stay symmetric.
|
||||
function parseClampArgs(raw) {
|
||||
const match = /^clamp\(\s*([\s\S]+)\s*\)$/i.exec(String(raw ?? '').trim());
|
||||
if (!match) return null;
|
||||
const args = splitTopLevelArgs(match[1]);
|
||||
return args.length === 3 ? args : null;
|
||||
}
|
||||
|
||||
// A fluid role declares its two fixed endpoints and interpolates between them
|
||||
// with a viewport unit. Both endpoints are documented sizes, so they belong in
|
||||
// the allowlist; the middle term is viewport-relative and never a fixed step.
|
||||
// Endpoints are marked `fluid` because they do not *enumerate* a ramp: see
|
||||
// `hasFontSizes` below for why that distinction has to survive.
|
||||
function addClampEndpoints(out, raw) {
|
||||
const args = parseClampArgs(raw);
|
||||
if (!args) return false;
|
||||
addFontSizeStep(out, args[0], { fluid: true });
|
||||
addFontSizeStep(out, args[2], { fluid: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitTopLevelArgs(s) {
|
||||
const args = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const ch of String(s)) {
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth--;
|
||||
if (ch === ',' && depth === 0) {
|
||||
args.push(current.trim());
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) args.push(current.trim());
|
||||
return args;
|
||||
}
|
||||
|
||||
function addTypographySizes(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
|
||||
// `scale` is the enumerated ramp: a name -> size map, since the frontmatter
|
||||
// parser has no list support. It sits alongside the named roles.
|
||||
const scale = typography.scale;
|
||||
if (scale && typeof scale === 'object') {
|
||||
for (const value of Object.values(scale)) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') continue;
|
||||
addFontSizeStep(out, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, role] of Object.entries(typography)) {
|
||||
if (name === 'scale') continue;
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
const raw = String(role.fontSize ?? '').trim().toLowerCase();
|
||||
if (addClampEndpoints(out, raw)) continue;
|
||||
addFontSizeStep(out, raw);
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedScale(out, rounded) {
|
||||
if (!rounded || typeof rounded !== 'object') return;
|
||||
for (const [rawName, value] of Object.entries(rounded)) {
|
||||
const name = unquoteYamlKey(rawName).toLowerCase();
|
||||
addRoundedToken(out, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedToken(out, name, value) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return;
|
||||
const raw = String(value).trim();
|
||||
if (!raw || /var\(/i.test(raw) || raw.includes('%')) return;
|
||||
const px = resolveLengthPx(raw, 16);
|
||||
if (px == null || !Number.isFinite(px)) return;
|
||||
out.allowedRadii.push({ name, value: raw, px });
|
||||
if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true;
|
||||
}
|
||||
|
||||
function addSidecarRadii(out, sidecar) {
|
||||
const roundedMeta = sidecar?.extensions?.roundedMeta;
|
||||
if (!roundedMeta || typeof roundedMeta !== 'object') return;
|
||||
|
||||
for (const [rawName, meta] of Object.entries(roundedMeta)) {
|
||||
const name = unquoteYamlKey(rawName).toLowerCase();
|
||||
if (typeof meta === 'string' || typeof meta === 'number') {
|
||||
addRoundedToken(out, `sidecar.${name}`, meta);
|
||||
continue;
|
||||
}
|
||||
if (!meta || typeof meta !== 'object') continue;
|
||||
for (const key of ['canonical', 'value']) {
|
||||
if (typeof meta[key] === 'string' || typeof meta[key] === 'number') {
|
||||
addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]);
|
||||
}
|
||||
}
|
||||
for (const key of ['values', 'aliases']) {
|
||||
if (!Array.isArray(meta[key])) continue;
|
||||
for (const [index, value] of meta[key].entries()) {
|
||||
addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value);
|
||||
}
|
||||
}
|
||||
if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) {
|
||||
out.hasPillRadius = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
const out = {
|
||||
present: true,
|
||||
sourcePath: input.sourcePath || null,
|
||||
sidecarPath: input.sidecarPath || null,
|
||||
mdNewerThanJson: input.mdNewerThanJson === true,
|
||||
allowedFonts: new Set(),
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(out, frontmatter.typography);
|
||||
addTypographySizes(out, frontmatter.typography);
|
||||
addColorObject(out, frontmatter.colors);
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
out.hasRadii = out.allowedRadii.length > 0;
|
||||
// Gate on *enumerated* steps only. A fully fluid system declares clamp
|
||||
// endpoints but no discrete ramp, so treating those endpoints as the whole
|
||||
// allowlist would flag every intermediate size. Abstain instead.
|
||||
out.hasFontSizes = out.allowedFontSizes.some(entry => !entry.fluid);
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadDesignSystemForCwd(cwd = process.cwd()) {
|
||||
const md = resolveDesignMdPath(cwd);
|
||||
if (!md) return null;
|
||||
|
||||
let frontmatter = null;
|
||||
let mdStat = null;
|
||||
try {
|
||||
mdStat = fs.statSync(md.path);
|
||||
frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!frontmatter || typeof frontmatter !== 'object') return null;
|
||||
|
||||
const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir);
|
||||
const sidecar = safeReadJson(sidecarPath);
|
||||
let sidecarStat = null;
|
||||
try {
|
||||
if (sidecarPath) sidecarStat = fs.statSync(sidecarPath);
|
||||
} catch {
|
||||
sidecarStat = null;
|
||||
}
|
||||
|
||||
return normalizeDesignSystem({
|
||||
frontmatter,
|
||||
sidecar,
|
||||
sourcePath: md.path,
|
||||
sidecarPath,
|
||||
mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000),
|
||||
});
|
||||
}
|
||||
|
||||
// Directory to begin the project-root walk from, given a scan target that may
|
||||
// be a file or a directory (and may not exist yet).
|
||||
function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
// Nonexistent path: treat an extension-bearing leaf as a file.
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
// Returns { dir, hasDesign } for the stopping directory, or null when the walk
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the design system that governs a specific scan target, by walking up
|
||||
// from the target's own location — never process.cwd(). Scanning project B's
|
||||
// files from inside project A applies B's DESIGN.md (or none), not A's.
|
||||
//
|
||||
// Pass a `cache` Map to memoize by resolved design root across a multi-file
|
||||
// scan; a target with no design root above it resolves to null.
|
||||
export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) {
|
||||
const startDir = designSystemStartDir(targetPath, cwd);
|
||||
const found = findDesignRoot(startDir);
|
||||
const key = found ? `root:${found.dir}` : '\0none';
|
||||
if (cache && cache.has(key)) return cache.get(key);
|
||||
const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null;
|
||||
if (cache) cache.set(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function isAllowedFont(font, designSystem) {
|
||||
if (!font || GENERIC_FONTS.has(font)) return true;
|
||||
if (!designSystem?.hasFonts) return true;
|
||||
return designSystem.allowedFonts.has(font);
|
||||
}
|
||||
|
||||
function isAllowedColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasColors) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
|
||||
if (text.includes('var(')) return true;
|
||||
const parsed = parseDesignColor(text);
|
||||
if (!parsed) return true;
|
||||
if ((parsed.a ?? 1) <= 0.05) return true;
|
||||
for (const entry of designSystem.allowedColorKeys.values()) {
|
||||
if (colorsClose(parsed, entry.color)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
|
||||
if (text.includes('var(') || text.includes('%')) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true;
|
||||
if (designSystem.hasPillRadius && px >= 99) return true;
|
||||
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
// One term of a font-size value. `unjudgeable` covers var(), calc(), percentages
|
||||
// and units the ramp cannot resolve (em is parent-relative, not root-relative);
|
||||
// those abstain rather than guess.
|
||||
function fontSizeStepStatus(raw, designSystem) {
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return 'unjudgeable';
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return 'unjudgeable';
|
||||
return designSystem.allowedFontSizes.some(
|
||||
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
|
||||
) ? 'on-ramp' : 'off-ramp';
|
||||
}
|
||||
|
||||
// The off-ramp endpoints of a fluid value, or null when `raw` is not a fluid
|
||||
// value at all. Only the min and max are judged: the viewport term interpolates
|
||||
// between them and is never a fixed step.
|
||||
//
|
||||
// Reading clamp endpoints as documented steps without also checking them in
|
||||
// usage would let `clamp(99rem, 1vw, 200rem)` through, which is how a fluid
|
||||
// declaration stayed invisible until someone measured computed styles.
|
||||
export function offRampClampEndpoints(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return null;
|
||||
const args = parseClampArgs(String(raw || '').trim().replace(/\s*!important\s*$/i, ''));
|
||||
if (!args) return null;
|
||||
return [args[0], args[2]].filter(
|
||||
endpoint => fontSizeStepStatus(endpoint, designSystem) === 'off-ramp',
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedFontSizeRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return true;
|
||||
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
|
||||
const offRampEndpoints = offRampClampEndpoints(text, designSystem);
|
||||
if (offRampEndpoints) return offRampEndpoints.length === 0;
|
||||
return fontSizeStepStatus(text, designSystem) !== 'off-ramp';
|
||||
}
|
||||
|
||||
function lineLooksCommented(line) {
|
||||
const trimmed = String(line || '').trim();
|
||||
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
|
||||
}
|
||||
|
||||
function isProbablyColorLiteral(line, match) {
|
||||
const raw = match?.[0] || '';
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
if (isInsideCssAttributeSelector(line, index)) return false;
|
||||
|
||||
const before = line.slice(0, index);
|
||||
const after = line.slice(index + raw.length);
|
||||
|
||||
if (raw.startsWith('#')) {
|
||||
if (before.endsWith('&')) return false; // HTML numeric entity, e.g. ↔
|
||||
|
||||
const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';
|
||||
const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';
|
||||
if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155
|
||||
}
|
||||
|
||||
const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);
|
||||
const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);
|
||||
const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);
|
||||
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
const lastOpen = before.lastIndexOf('[');
|
||||
if (lastOpen === -1) return false;
|
||||
const lastClose = before.lastIndexOf(']');
|
||||
if (lastClose > lastOpen) return false;
|
||||
const after = line.slice(index);
|
||||
const close = after.indexOf(']');
|
||||
const block = after.indexOf('{');
|
||||
return close !== -1 && (block === -1 || close < block);
|
||||
}
|
||||
|
||||
function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {
|
||||
return { ...finding(id, filePath, snippet, line), ...extras };
|
||||
}
|
||||
|
||||
function decodeGoogleFamily(value) {
|
||||
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
|
||||
try {
|
||||
return decodeURIComponent(family);
|
||||
} catch {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
function checkFontStack(stack, filePath, line, designSystem, context) {
|
||||
const primary = primaryFont(stack);
|
||||
if (!primary || isAllowedFont(primary, designSystem)) return [];
|
||||
const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());
|
||||
return [makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`${context}: ${display} is not declared in DESIGN.md typography`,
|
||||
line,
|
||||
{ ignoreValue: display },
|
||||
)];
|
||||
}
|
||||
|
||||
function extractRadiusTokens(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*\/\s*/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
const findings = [];
|
||||
for (const token of extractRadiusTokens(value)) {
|
||||
if (isAllowedRadiusRaw(token, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-radius',
|
||||
filePath,
|
||||
`${context}: ${token} is outside the DESIGN.md rounded scale`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkFontSizeValue(value, filePath, line, designSystem, context) {
|
||||
const token = String(value || '').trim();
|
||||
if (isAllowedFontSizeRaw(token, designSystem)) return [];
|
||||
|
||||
// Name the offending endpoint on a fluid value; the whole clamp() string is
|
||||
// not actionable on its own, and it makes a poor ignore-value.
|
||||
const offRampEndpoints = offRampClampEndpoints(token, designSystem) || [];
|
||||
if (offRampEndpoints.length > 0) {
|
||||
const plural = offRampEndpoints.length > 1 ? 's' : '';
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} has fluid endpoint${plural} ${offRampEndpoints.join(' and ')} off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: offRampEndpoints[0] },
|
||||
)];
|
||||
}
|
||||
|
||||
// The snippet shows the declaration as authored, but the ignoreValue has to
|
||||
// be what a `hooks ignore-value` waiver can match, so the priority marker is
|
||||
// stripped. Otherwise the same size needs two different waivers depending on
|
||||
// whether it carries !important. font-family already behaves this way.
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} is off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: token.replace(/\s*!important\s*$/i, '').trim() },
|
||||
)];
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
|
||||
const findings = [];
|
||||
const lines = String(content || '').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
if (lineLooksCommented(line)) continue;
|
||||
|
||||
if (designSystem.hasFonts) {
|
||||
for (const match of line.matchAll(FONT_DECL_RE)) {
|
||||
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_JS_RE)) {
|
||||
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));
|
||||
}
|
||||
for (const match of line.matchAll(GOOGLE_FONT_RE)) {
|
||||
const url = match[0];
|
||||
for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {
|
||||
const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));
|
||||
if (!font || isAllowedFont(font, designSystem)) continue;
|
||||
const display = decodeGoogleFamily(familyMatch[1]);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`Google Fonts: ${display} is not declared in DESIGN.md typography`,
|
||||
lineNum,
|
||||
{ ignoreValue: display },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
for (const match of line.matchAll(CSS_COLOR_RE)) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
`Undocumented color ${raw} is outside DESIGN.md colors`,
|
||||
lineNum,
|
||||
{ ignoreValue: raw },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
for (const match of line.matchAll(BORDER_RADIUS_RE)) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));
|
||||
}
|
||||
for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasFontSizes) {
|
||||
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
|
||||
}
|
||||
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
}
|
||||
|
||||
function hasDirectText(el) {
|
||||
return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
function sampleText(el) {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
|
||||
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
const seenFonts = new Set();
|
||||
const seenColors = new Set();
|
||||
const seenRadii = new Set();
|
||||
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (shouldSkipStaticDesignElement(el, window)) continue;
|
||||
const tag = el.tagName?.toLowerCase?.() || 'unknown';
|
||||
const style = window.getComputedStyle(el);
|
||||
|
||||
if (designSystem.hasFonts && hasDirectText(el)) {
|
||||
const font = primaryFont(style.fontFamily || '');
|
||||
if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {
|
||||
seenFonts.add(font);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-font',
|
||||
filePath,
|
||||
`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
|
||||
0,
|
||||
{ ignoreValue: font },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasColors) {
|
||||
const colorChecks = [];
|
||||
if (hasDirectText(el)) colorChecks.push(['text color', style.color]);
|
||||
if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
|
||||
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
|
||||
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
|
||||
}
|
||||
}
|
||||
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
|
||||
|
||||
for (const [kind, raw] of colorChecks) {
|
||||
const label = cssColorLabel(raw);
|
||||
if (isAllowedColorRaw(label, designSystem)) continue;
|
||||
const key = `${kind}:${label}`;
|
||||
if (seenColors.has(key)) continue;
|
||||
seenColors.add(key);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,
|
||||
0,
|
||||
{ ignoreValue: label },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasRadii) {
|
||||
const rawRadius = String(style.borderRadius || '').trim();
|
||||
if (!rawRadius) continue;
|
||||
for (const token of extractRadiusTokens(rawRadius)) {
|
||||
if (isAllowedRadiusRaw(token, designSystem)) continue;
|
||||
if (seenRadii.has(token)) continue;
|
||||
seenRadii.add(token);
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-radius',
|
||||
filePath,
|
||||
`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,
|
||||
0,
|
||||
{ ignoreValue: token },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function shouldSkipStaticDesignElement(el, window) {
|
||||
const tag = el.tagName?.toLowerCase?.() || '';
|
||||
if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;
|
||||
|
||||
let current = el;
|
||||
while (current) {
|
||||
if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;
|
||||
const style = window.getComputedStyle(current);
|
||||
const display = String(style.display || '').toLowerCase();
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTransparentCss(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text || text === 'transparent') return true;
|
||||
const parsed = parseDesignColor(text);
|
||||
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
|
||||
}
|
||||
|
||||
function canonicalDesignFindingKey(item) {
|
||||
if (!item?.antipattern?.startsWith?.('design-system-')) return null;
|
||||
const value = item.ignoreValue || item.value || '';
|
||||
if (item.antipattern === 'design-system-font') {
|
||||
const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';
|
||||
const font = normalizeFontName(value);
|
||||
return font ? `${item.antipattern}:${context}:${font}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-color') {
|
||||
const parsed = parseDesignColor(value);
|
||||
if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;
|
||||
const label = cssColorLabel(value).toLowerCase();
|
||||
return label ? `${item.antipattern}:color:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-radius') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-font-size') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:font-size:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeDesignSystemFindings(...groups) {
|
||||
const out = [];
|
||||
const seen = new Map();
|
||||
for (const group of groups) {
|
||||
for (const item of group || []) {
|
||||
const key = canonicalDesignFindingKey(item);
|
||||
if (key) {
|
||||
if (seen.has(key)) {
|
||||
const existing = out[seen.get(key)];
|
||||
if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;
|
||||
continue;
|
||||
}
|
||||
seen.set(key, out.length);
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupeDesignFindings(findings) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const item of findings) {
|
||||
const key = [
|
||||
item.antipattern,
|
||||
item.line || 0,
|
||||
normalizeFontName(item.ignoreValue || item.snippet || ''),
|
||||
].join('\0');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export {
|
||||
parseFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,13 @@ export {
|
||||
checkHtmlPatterns,
|
||||
} from './rules/checks.mjs';
|
||||
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
|
||||
export {
|
||||
parseFrontmatter as parseDesignFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
} from './design-system.mjs';
|
||||
export { detectHtml } from './engines/static-html/detect-html.mjs';
|
||||
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
|
||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||
|
||||
@@ -3,9 +3,87 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
|
||||
|
||||
// On Windows, puppeteer's bundled Chrome lives in a user-writable cache
|
||||
// directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security
|
||||
// software or the GPU sandbox because it launches from an untrusted path.
|
||||
// Chrome then crash-loops the GPU process, and each relaunch briefly flashes a
|
||||
// compositor surface, the black window users report during `detect <url>`
|
||||
// (issue #372). The system-installed Chrome runs from a trusted location with a
|
||||
// healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware
|
||||
// GPU, so contrast measurement is unaffected. Scope this to Windows only: other
|
||||
// platforms do not have the bug, so they keep the pinned bundled build for
|
||||
// consistent measurement across machines. Fall back to bundled when the switch
|
||||
// fails (Chrome not installed, or channel resolution fails). If the bundled
|
||||
// launch then also fails, surface the original system-Chrome error as the
|
||||
// cause so the real failure is not lost.
|
||||
async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) {
|
||||
let channelError;
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
return await puppeteer.default.launch({ channel: 'chrome', headless, args });
|
||||
} catch (err) {
|
||||
// System Chrome unavailable or unlaunchable; fall through to the bundled
|
||||
// browser, but keep the error in case the fallback fails too.
|
||||
channelError = err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await puppeteer.default.launch({ headless, args });
|
||||
} catch (err) {
|
||||
if (channelError && err && err.cause === undefined) err.cause = channelError;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal sweep + invisible-text measurement for the content-hidden-at-rest
|
||||
// rule. Scrolls through the document with instant jumps (bypasses CSS
|
||||
// scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers
|
||||
// get every chance to fire, returns to the top, lets transitions settle,
|
||||
// then measures how much text still renders invisible. A healthy
|
||||
// reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal
|
||||
// script died keeps most of its text at opacity 0.
|
||||
async function measureContentHiddenAfterReveal(page) {
|
||||
await page.evaluate(async () => {
|
||||
const step = Math.max(200, Math.floor(window.innerHeight * 0.7));
|
||||
const max = Math.max(
|
||||
document.documentElement.scrollHeight || 0,
|
||||
document.body?.scrollHeight || 0,
|
||||
);
|
||||
for (let y = 0; y <= max; y += step) {
|
||||
window.scrollTo({ top: y, left: 0, behavior: 'instant' });
|
||||
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40)));
|
||||
}
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
});
|
||||
return page.evaluate(() => {
|
||||
if (typeof window.impeccableMeasureHiddenText !== 'function') return null;
|
||||
return window.impeccableMeasureHiddenText();
|
||||
});
|
||||
}
|
||||
|
||||
function serializeDesignSystemForBrowser(designSystem) {
|
||||
if (!designSystem?.present) return null;
|
||||
return {
|
||||
present: true,
|
||||
hasFonts: designSystem.hasFonts === true,
|
||||
allowedFonts: Array.from(designSystem.allowedFonts || []),
|
||||
hasColors: designSystem.hasColors === true,
|
||||
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
|
||||
.map(entry => entry?.color)
|
||||
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
||||
.map(color => ({ r: color.r, g: color.g, b: color.b })),
|
||||
hasRadii: designSystem.hasRadii === true,
|
||||
allowedRadii: (designSystem.allowedRadii || [])
|
||||
.map(entry => Number(entry?.px))
|
||||
.filter(px => Number.isFinite(px)),
|
||||
hasPillRadius: designSystem.hasPillRadius === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
|
||||
if (options?.visualContrast === false) return [];
|
||||
@@ -132,13 +210,26 @@ async function detectUrl(url, options = {}) {
|
||||
phase: 'load',
|
||||
ruleId: 'launch-browser',
|
||||
target: url,
|
||||
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
|
||||
}, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs }));
|
||||
const page = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'new-page',
|
||||
target: url,
|
||||
}, () => browser.newPage());
|
||||
|
||||
// Uncaught exceptions and parse errors surface as pageerror events. The
|
||||
// listener must attach before goto: a syntax error fires during the
|
||||
// initial parse, long before the load event. Dedupe by message; a single
|
||||
// broken loop can otherwise throw hundreds of identical errors.
|
||||
const pageErrors = [];
|
||||
if (options?.scriptErrors !== false) {
|
||||
page.on('pageerror', (err) => {
|
||||
const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160);
|
||||
if (message && !pageErrors.includes(message)) pageErrors.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
await profileStepAsync(profile, {
|
||||
@@ -163,17 +254,19 @@ async function detectUrl(url, options = {}) {
|
||||
}
|
||||
|
||||
// Inject the browser detection script and collect results
|
||||
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'configure-pure-detect',
|
||||
target: url,
|
||||
}, () => page.evaluate(() => {
|
||||
}, () => page.evaluate((designSystem) => {
|
||||
window.__IMPECCABLE_CONFIG__ = {
|
||||
...(window.__IMPECCABLE_CONFIG__ || {}),
|
||||
autoScan: false,
|
||||
...(designSystem ? { designSystem } : {}),
|
||||
};
|
||||
}));
|
||||
}, browserDesignSystem));
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
@@ -192,9 +285,29 @@ async function detectUrl(url, options = {}) {
|
||||
return window.impeccableDetect({ decorate: false, serialize: true });
|
||||
});
|
||||
return serializedGroups.flatMap(({ findings }) =>
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail }))
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
|
||||
);
|
||||
});
|
||||
// Content invisible at rest: reveal sweep, then re-measure. Runs after
|
||||
// the main scan (which must see the true at-rest state) and before the
|
||||
// visual contrast fallback (the sweep restores scroll to the top).
|
||||
if (options?.contentHidden !== false) {
|
||||
const hiddenFindings = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'content-hidden-at-rest',
|
||||
target: url,
|
||||
}, async () => {
|
||||
const measured = await measureContentHiddenAfterReveal(page);
|
||||
return measured ? checkContentHiddenAtRest(measured) : [];
|
||||
});
|
||||
results.push(...hiddenFindings);
|
||||
}
|
||||
|
||||
for (const message of pageErrors.slice(0, 3)) {
|
||||
results.push({ id: 'script-error', snippet: message });
|
||||
}
|
||||
|
||||
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
|
||||
results.push(...visualFindings);
|
||||
} finally {
|
||||
@@ -213,7 +326,14 @@ async function detectUrl(url, options = {}) {
|
||||
}, () => browser.close());
|
||||
}
|
||||
}
|
||||
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
|
||||
return results.map(f => {
|
||||
const item = finding(f.id, url, f.snippet);
|
||||
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
|
||||
// Per-finding severity promotion (e.g. hero-region pulsing dot)
|
||||
// overrides the registry default carried by finding().
|
||||
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
async function createBrowserDetector(options = {}) {
|
||||
@@ -224,7 +344,7 @@ async function createBrowserDetector(options = {}) {
|
||||
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
|
||||
}
|
||||
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
|
||||
const browser = options.browser || await puppeteer.default.launch({
|
||||
const browser = options.browser || await launchBrowser(puppeteer, {
|
||||
headless: options.headless ?? true,
|
||||
args: launchArgs,
|
||||
});
|
||||
@@ -249,4 +369,4 @@ async function createBrowserDetector(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForPseudoStripe, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
||||
const hasRounded = (line) =>
|
||||
/\brounded(?:-\w+)?\b/.test(line.replace(/\brounded-none\b/g, ''));
|
||||
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
||||
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
||||
|
||||
|
||||
/** Strip HTML to plain text — drops script/style/comments/tags so
|
||||
* content-text analyzers don't false-positive on code or CSS. */
|
||||
function stripHtmlToText(html) {
|
||||
@@ -35,31 +41,107 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
if (!m) return false;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
|
||||
// CSS named colors whose channels are equal (achromatic). Anything outside
|
||||
// this set falls through to the format parsers, and an unrecognized spelling
|
||||
// stays non-neutral so a real accent is never skipped.
|
||||
const NEUTRAL_COLOR_KEYWORDS = new Set([
|
||||
'transparent', 'currentcolor',
|
||||
'black', 'white', 'gray', 'grey', 'silver',
|
||||
'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey',
|
||||
'gainsboro', 'whitesmoke',
|
||||
]);
|
||||
|
||||
function hexChannels(color) {
|
||||
const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i);
|
||||
if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)];
|
||||
const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i);
|
||||
if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one box-shadow layer into top-level tokens.
|
||||
*
|
||||
* Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and
|
||||
* `var(--x, 4px)` are each a single value, and splitting them on spaces would
|
||||
* read their innards as separate lengths.
|
||||
*/
|
||||
function tokenizeShadowLayer(layer) {
|
||||
const tokens = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const char of String(layer || '')) {
|
||||
if (char === '(') depth++;
|
||||
else if (char === ')') depth--;
|
||||
else if (depth === 0 && /\s/.test(char)) {
|
||||
if (current) tokens.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
||||
if (shex) {
|
||||
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function lastMatch(text, re) {
|
||||
const all = [...String(text || '').matchAll(re)];
|
||||
return all.length ? all[all.length - 1] : null;
|
||||
}
|
||||
|
||||
function isShadowLength(token) {
|
||||
return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutrality test for colors as written in source CSS.
|
||||
*
|
||||
* shared/color.mjs's isNeutralColor only parses the computed function forms a
|
||||
* browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
|
||||
* other spelling as chromatic so an unknown format is never silently skipped.
|
||||
* That default is wrong for authored CSS, where `#000` and `black` are the
|
||||
* normal spellings: calling it directly reports a plain black hairline as a
|
||||
* colored stripe. Handle hex and named neutrals here, then defer.
|
||||
*/
|
||||
function isNeutralAuthoredColor(rawColor) {
|
||||
const c = String(rawColor || '').trim().toLowerCase();
|
||||
if (!c) return false;
|
||||
if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true;
|
||||
// Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs
|
||||
// parses only the comma form a browser's getComputedStyle emits, so authored
|
||||
// space-separated neutrals fell through it and reported as chromatic — the
|
||||
// exemption this function exists for, missed. Normalize before delegating.
|
||||
if (/^rgba?\(/i.test(c)) {
|
||||
const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
|
||||
if (channels) {
|
||||
const values = [1, 2, 3].map((i) => Number(channels[i]));
|
||||
return (Math.max(...values) - Math.min(...values)) < 30;
|
||||
}
|
||||
return isNeutralColor(c);
|
||||
}
|
||||
if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const channels = hexChannels(c);
|
||||
if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
||||
if (!m) return false;
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
|
||||
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
|
||||
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
|
||||
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
||||
@@ -84,9 +166,12 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
|
||||
test: (m) => {
|
||||
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
|
||||
return Boolean(m.overusedGoogleFont);
|
||||
},
|
||||
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
|
||||
// --- Gradient text ---
|
||||
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
||||
test: (m, line) => /gradient/i.test(line),
|
||||
@@ -156,27 +241,6 @@ const REGEX_MATCHERS = [
|
||||
];
|
||||
|
||||
const REGEX_ANALYZERS = [
|
||||
// Single font
|
||||
(content, filePath) => {
|
||||
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
|
||||
const fonts = new Set();
|
||||
let m;
|
||||
while ((m = fontFamilyRe.exec(content)) !== null) {
|
||||
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
||||
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
|
||||
}
|
||||
}
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
while ((m = gfRe.exec(content)) !== null) {
|
||||
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
|
||||
}
|
||||
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
||||
const name = [...fonts][0];
|
||||
const lines = content.split('\n');
|
||||
let line = 1;
|
||||
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
|
||||
return [finding('single-font', filePath, `only font used is ${name}`, line)];
|
||||
},
|
||||
// Flat type hierarchy
|
||||
(content, filePath) => {
|
||||
const sizes = new Set();
|
||||
@@ -226,15 +290,34 @@ const REGEX_ANALYZERS = [
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
||||
// (occasional em-dash use in prose is fine; the pattern fires only
|
||||
// when count crosses into AI-cadence territory).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
@@ -270,22 +353,6 @@ const REGEX_ANALYZERS = [
|
||||
if (count === 0) return [];
|
||||
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
|
||||
},
|
||||
// Numbered section markers (01 / 02 / 03 ...)
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const re = /\b(0[1-9]|1[0-2])\b/g;
|
||||
const seen = new Set();
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) seen.add(m[1]);
|
||||
if (seen.size < 3) return [];
|
||||
const sorted = [...seen].sort();
|
||||
let sequential = 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
|
||||
}
|
||||
if (sequential < 2) return [];
|
||||
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
|
||||
},
|
||||
// Aphoristic cadence: manufactured-contrast + short-rebuttal
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
@@ -307,41 +374,143 @@ const REGEX_ANALYZERS = [
|
||||
if (count < 3) return [];
|
||||
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
|
||||
},
|
||||
// Dark glow (page-level: dark bg + colored box-shadow with blur)
|
||||
// Dark glow / chromatic halo shadows (page-level). Shared scanner handles
|
||||
// any color format, single-level var() resolution, zero-offset halos on
|
||||
// any background, and text-shadow glows.
|
||||
(content, filePath) => {
|
||||
// Check if page has a dark background
|
||||
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
|
||||
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
|
||||
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
|
||||
if (!hasDarkBg) return [];
|
||||
|
||||
// Check for colored box-shadow with blur > 4px
|
||||
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
|
||||
let m;
|
||||
while ((m = shadowRe.exec(content)) !== null) {
|
||||
const val = m[1];
|
||||
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!colorMatch) continue;
|
||||
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
|
||||
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
|
||||
// Check blur: look for pattern like "0 0 20px" (third number > 4)
|
||||
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
|
||||
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
||||
const lines = content.substring(0, m.index).split('\n');
|
||||
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
const hits = scanCssTextForGlow(content);
|
||||
if (hits.length === 0) return [];
|
||||
const lines = content.substring(0, hits[0].index).split('\n');
|
||||
return [finding('dark-glow', filePath, hits[0].snippet, lines.length)];
|
||||
},
|
||||
// Radial-gradient background halo on a dark page (the gradient sibling
|
||||
// of the dark-glow shadow tell).
|
||||
(content, filePath) => {
|
||||
const hits = scanCssTextForRadialHalo(content);
|
||||
if (hits.length === 0) return [];
|
||||
const lines = content.substring(0, hits[0].index).split('\n');
|
||||
return [finding('radial-halo', filePath, hits[0].snippet, lines.length)];
|
||||
},
|
||||
// Auto-scrolling marquees (<marquee> or infinite horizontal loop
|
||||
// animations).
|
||||
(content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Vue/Svelte <style> blocks)
|
||||
// Structural CSS checks used by source files whose styles are not parsed by
|
||||
// the static HTML engine.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i;
|
||||
|
||||
function insetStripeColorIsChromatic(rawColor) {
|
||||
const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, '');
|
||||
if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false;
|
||||
const variable = color.match(/^var\(\s*(--[\w-]+)/i);
|
||||
if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]);
|
||||
if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false;
|
||||
return !isNeutralAuthoredColor(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank out comment bodies while preserving every byte offset (and therefore
|
||||
* every line number) so commented-out CSS is not scanned as live rules.
|
||||
*/
|
||||
function blankCssComments(css) {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
|
||||
const content = blankCssComments(rawContent);
|
||||
const findings = [];
|
||||
const ruleRe = /([^{};]+)\{([^{}]*)\}/g;
|
||||
let match;
|
||||
// Deriving each line with content.slice(0, offset).split('\n') re-scans the
|
||||
// whole prefix per rule, which is O(n^2) on a large stylesheet. Rule matches
|
||||
// arrive in source order, so carry a monotonic cursor instead: one pass total.
|
||||
let scanOffset = 0;
|
||||
let scanLine = 1;
|
||||
const lineAtOffset = (offset) => {
|
||||
while (scanOffset < offset) {
|
||||
if (content[scanOffset] === '\n') scanLine++;
|
||||
scanOffset++;
|
||||
}
|
||||
return scanLine;
|
||||
};
|
||||
while ((match = ruleRe.exec(content)) !== null) {
|
||||
// The selector group is `[^{};]+`, which greedily absorbs the whitespace and
|
||||
// newlines trailing the previous rule. Advance past that run before deriving
|
||||
// the line, or every rule after the first reports the preceding line.
|
||||
const selectorStart = match.index + (match[1].length - match[1].trimStart().length);
|
||||
const selector = match[1].trim().replace(/\s+/g, ' ');
|
||||
if (!selector) continue;
|
||||
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
|
||||
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
|
||||
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
|
||||
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
|
||||
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
|
||||
|
||||
// Read the last of a repeated declaration, not the first: that is what the
|
||||
// cascade paints. Taking the first both flagged stripes that a later
|
||||
// `box-shadow: none` had cancelled and missed stripes that overrode an
|
||||
// earlier value, and mis-skipped rules whose narrow width was overridden.
|
||||
const width = lastMatch(match[2], /(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/gi);
|
||||
if (width && Number(width[1]) <= 40) continue;
|
||||
const declaration = lastMatch(match[2], /(?:^|;)\s*box-shadow\s*:\s*([^;]+)/gi);
|
||||
if (!declaration || !/\binset\b/i.test(declaration[1])) continue;
|
||||
// `!important` qualifies the declaration, not the shadow value, so strip it
|
||||
// before the layers are read. Tokenizing split it into its own token, which
|
||||
// made the color count wrong and silently stopped flagging stripes declared
|
||||
// with it — a shape the previous regex handled.
|
||||
const shadowValue = declaration[1].replace(/\s*!\s*important\s*$/i, '').trim();
|
||||
|
||||
for (const rawLayer of shadowValue.split(/,(?![^(]*\))/)) {
|
||||
const layer = rawLayer.trim();
|
||||
// Parse the layer by its grammar rather than by one spelling of it.
|
||||
// A box-shadow layer is `inset? && <length>{2,4} && <color>?` in any
|
||||
// order, so `inset 4px 0 red`, `4px 0 0 red inset`, and `red 4px 0 inset`
|
||||
// all paint the same stripe. Matching a fixed token order missed three
|
||||
// valid spellings in a row; enumerate the tokens instead. Tokenizing must
|
||||
// respect parens: `rgb(0 0 0)` is one color token, and splitting it on
|
||||
// whitespace would read its channels as lengths.
|
||||
const tokens = tokenizeShadowLayer(layer);
|
||||
if (!tokens.some((token) => /^inset$/i.test(token))) continue;
|
||||
const rest = tokens.filter((token) => !/^inset$/i.test(token));
|
||||
const lengths = rest.filter(isShadowLength);
|
||||
const colors = rest.filter((token) => !isShadowLength(token));
|
||||
// Only the two offsets are required; omitted blur/spread default to 0,
|
||||
// which is exactly the stripe shape. More than one non-length token is a
|
||||
// layer shape we do not claim to understand, so leave it alone.
|
||||
if (lengths.length < 2 || lengths.length > 4 || colors.length !== 1) continue;
|
||||
const values = lengths.map((token) => ({
|
||||
n: Number(token.replace(/px$/i, '')),
|
||||
hasPx: /px$/i.test(token),
|
||||
}));
|
||||
const x = values[0];
|
||||
const y = values[1];
|
||||
const blur = values[2] ? values[2].n : 0;
|
||||
const spread = values[3] ? values[3].n : 0;
|
||||
if ((x.n !== 0 && !x.hasPx) || (y.n !== 0 && !y.hasPx) || blur !== 0 || spread !== 0) continue;
|
||||
const ax = Math.abs(x.n);
|
||||
const ay = Math.abs(y.n);
|
||||
if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue;
|
||||
if (!insetStripeColorIsChromatic(colors[0])) continue;
|
||||
const edge = ay === 0 ? (x.n > 0 ? 'left' : 'right') : (y.n > 0 ? 'top' : 'bottom');
|
||||
const line = lineOffset + lineAtOffset(selectorStart);
|
||||
findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Astro/Vue/Svelte <style> blocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractStyleBlocks(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext !== '.vue' && ext !== '.svelte') return [];
|
||||
if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
|
||||
const blocks = [];
|
||||
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let m;
|
||||
@@ -426,24 +595,24 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null,
|
||||
}
|
||||
|
||||
/** Page-level analyzers that scan rendered text content (em-dash use,
|
||||
* buzzword phrases, numbered section markers, aphoristic cadence).
|
||||
* buzzword phrases, aphoristic cadence).
|
||||
* These are detector-agnostic — they work on any HTML/text source
|
||||
* and don't need a parsed DOM. Exported so detectHtml can call them
|
||||
* for `.html` files (which otherwise skip the regex engine). */
|
||||
const TEXT_CONTENT_ANALYZER_IDS = [
|
||||
'em-dash-overuse',
|
||||
'marketing-buzzword',
|
||||
'numbered-section-markers',
|
||||
'aphoristic-cadence',
|
||||
];
|
||||
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
|
||||
// The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
|
||||
// (single-font's removal on 2026-07-29 shifted every index down one).
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[3 + i];
|
||||
const analyzer = REGEX_ANALYZERS[2 + i];
|
||||
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
@@ -468,8 +637,36 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
// Pseudo-element stripes (::before/::after absolute bars) carry the same
|
||||
// side-tab silhouette without any border token, so the line matchers can't
|
||||
// see them (issue #394). The shared scanner already runs on full HTML pages
|
||||
// via checkHtmlPatterns; give standalone stylesheets, component style
|
||||
// blocks, and CSS-in-JS templates the same coverage. Each hit carries the
|
||||
// rule's source offset, so the finding gets a real line and line-scoped
|
||||
// inline ignores keep working.
|
||||
const pseudoStripeFindings = (text, lineOffset) =>
|
||||
scanCssTextForPseudoStripe(text).map(hit =>
|
||||
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
|
||||
|
||||
// Extract and scan <style> blocks from Vue/Svelte SFCs
|
||||
if (cssLike.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
|
||||
// Block-level CSS checks that need multiple declarations must run over the
|
||||
// complete source, not line-by-line. This covers standalone stylesheets,
|
||||
// component style blocks, inline styles, and CSS-in-JS templates.
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'source',
|
||||
ruleId: 'codex-grid-background',
|
||||
target: filePath,
|
||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
||||
const line = content.substring(0, hit.index).split('\n').length;
|
||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||
})));
|
||||
|
||||
// Extract and scan <style> blocks from Astro/Vue/Svelte components.
|
||||
const styleBlocks = profile
|
||||
? profileStep(profile, {
|
||||
engine: 'regex',
|
||||
@@ -484,6 +681,14 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
}));
|
||||
// block.startLine is the first line *after* the <style> tag, but block.content
|
||||
// begins at the character right after that tag — so its own line 1 sits on the
|
||||
// tag's line, whether or not a newline follows immediately. lineAtOffset is
|
||||
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
|
||||
// reported every selector one line low. runRegexMatchers keeps startLine - 1
|
||||
// because it indexes its split lines from zero.
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
@@ -501,6 +706,17 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
|
||||
}
|
||||
|
||||
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
||||
@@ -517,12 +733,10 @@ function detectText(content, filePath, options = {}) {
|
||||
// Page-level analyzers only run on full pages
|
||||
if (shouldRunPageAnalyzers(content, filePath)) {
|
||||
const analyzerIds = [
|
||||
'single-font',
|
||||
'flat-type-hierarchy',
|
||||
'monotonous-spacing',
|
||||
'em-dash-overuse',
|
||||
'marketing-buzzword',
|
||||
'numbered-section-markers',
|
||||
'aphoristic-cadence',
|
||||
'dark-glow',
|
||||
];
|
||||
@@ -537,7 +751,9 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? deduped : applyInlineIgnores(deduped, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
|
||||
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
|
||||
import { CSS_NAMED_COLORS, collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsdom CSS-variable border override map
|
||||
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATIC_INHERITED_PROPS = new Set([
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||
'webkitHyphens',
|
||||
]);
|
||||
@@ -245,9 +245,14 @@ const STATIC_DEFAULT_STYLE = {
|
||||
outlineColor: 'rgb(0, 0, 0)',
|
||||
outlineStyle: 'none',
|
||||
boxShadow: 'none',
|
||||
// NOT in STATIC_INHERITED_PROPS even though text-shadow inherits in real
|
||||
// CSS: the glow check only needs to fire once, on the element that
|
||||
// declares the shadow, not on every descendant.
|
||||
textShadow: 'none',
|
||||
fontFamily: '',
|
||||
fontSize: '16px',
|
||||
fontStyle: 'normal',
|
||||
fontVariant: 'normal',
|
||||
fontWeight: '400',
|
||||
lineHeight: 'normal',
|
||||
letterSpacing: 'normal',
|
||||
@@ -272,6 +277,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginBottom: '0px',
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -301,6 +307,7 @@ const STATIC_PROP_MAP = {
|
||||
'outline-color': 'outlineColor',
|
||||
'outline-style': 'outlineStyle',
|
||||
'box-shadow': 'boxShadow',
|
||||
'text-shadow': 'textShadow',
|
||||
'font-family': 'fontFamily',
|
||||
'font-size': 'fontSize',
|
||||
'font-style': 'fontStyle',
|
||||
@@ -326,6 +333,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-bottom': 'marginBottom',
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
@@ -337,18 +345,29 @@ const STATIC_PROP_MAP = {
|
||||
'overflow-y': 'overflowY',
|
||||
};
|
||||
|
||||
// parseStaticColor tries parseAnyColor first, which already resolves every
|
||||
// name in the shared CSS_NAMED_COLORS table. This fallback only carries the
|
||||
// keywords parseAnyColor deliberately returns null for: the cascade needs
|
||||
// `transparent` to read as an actual zero-alpha color.
|
||||
const STATIC_NAMED_COLORS = {
|
||||
black: { r: 0, g: 0, b: 0, a: 1 },
|
||||
white: { r: 255, g: 255, b: 255, a: 1 },
|
||||
transparent: { r: 0, g: 0, b: 0, a: 0 },
|
||||
gray: { r: 128, g: 128, b: 128, a: 1 },
|
||||
grey: { r: 128, g: 128, b: 128, a: 1 },
|
||||
silver: { r: 192, g: 192, b: 192, a: 1 },
|
||||
red: { r: 255, g: 0, b: 0, a: 1 },
|
||||
green: { r: 0, g: 128, b: 0, a: 1 },
|
||||
blue: { r: 0, g: 0, b: 255, a: 1 },
|
||||
};
|
||||
|
||||
// Named-color alternation for plucking a color token out of shorthand values
|
||||
// (issue #359: a hardcoded 9-name list here silently dropped `purple`,
|
||||
// `crimson`, `teal`, ... from border shorthands, so the side defaulted to
|
||||
// neutral black and side-tab never fired on .html files). Derived from the
|
||||
// same table parseAnyColor resolves against, so extraction and parsing can't
|
||||
// drift apart. Longest-first so names containing other names as substrings
|
||||
// (rebeccapurple) are matched whole.
|
||||
const NAMED_COLOR_TOKENS = [...Object.keys(CSS_NAMED_COLORS), ...Object.keys(STATIC_NAMED_COLORS)]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.join('|');
|
||||
const STATIC_COLOR_TOKEN_RE = new RegExp(
|
||||
`(?:rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|lch\\([^)]+\\)|lab\\([^)]+\\)|hsla?\\([^)]+\\)|hwb\\([^)]+\\)|#[0-9a-f]{3,8}\\b|\\b(?:${NAMED_COLOR_TOKENS})\\b)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
function splitCssList(value) {
|
||||
const parts = [];
|
||||
let depth = 0, quote = '', start = 0;
|
||||
@@ -418,7 +437,23 @@ function extractStaticColor(value) {
|
||||
if (!value) return '';
|
||||
const raw = String(value).trim();
|
||||
if (/^var\(/i.test(raw)) return raw;
|
||||
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
|
||||
// color-mix(...) needs balanced-paren capture (its arguments regularly
|
||||
// contain nested var()/oklch() calls AND the keyword `transparent`, which
|
||||
// the flat regex below would otherwise pluck out of the middle of the
|
||||
// expression and report as the whole color).
|
||||
const mixStart = raw.search(/color-mix\(/i);
|
||||
if (mixStart !== -1) {
|
||||
let depth = 0;
|
||||
for (let i = raw.indexOf('(', mixStart); i < raw.length; i++) {
|
||||
if (raw[i] === '(') depth++;
|
||||
else if (raw[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return raw.slice(mixStart, i + 1);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const colorLike = raw.match(STATIC_COLOR_TOKEN_RE);
|
||||
if (!colorLike) return '';
|
||||
return colorLike[0];
|
||||
}
|
||||
@@ -530,6 +565,15 @@ function expandStaticDeclaration(prop, value) {
|
||||
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
|
||||
const color = extractStaticColor(hasImage ? beforeImage : v);
|
||||
if (color) out.push(['backgroundColor', color]);
|
||||
// The `background` shorthand resets every longhand it does not set.
|
||||
// Without this, `pre code { background: none }` leaves an earlier
|
||||
// `background: var(--surface)` color standing and the contrast checks
|
||||
// measure text against a surface the browser never paints. var() values
|
||||
// stay untouched: they may resolve to a color later in the pipeline.
|
||||
if (!color && !hasImage && !/var\(/i.test(v)) {
|
||||
out.push(['backgroundColor', 'rgba(0, 0, 0, 0)']);
|
||||
out.push(['backgroundImage', 'none']);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (p === 'border') {
|
||||
@@ -700,7 +744,20 @@ function collectStaticCssRules(cssText, csstree) {
|
||||
});
|
||||
});
|
||||
for (const selector of splitCssList(selectorText)) {
|
||||
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
|
||||
if (!selector) continue;
|
||||
// :hover rules can't be matched statically as-is (no interaction
|
||||
// state), but they carry real cascade weight while hovered. Tag
|
||||
// them and record a state-stripped selector so the hover pass can
|
||||
// find their targets; specificity stays computed from the ORIGINAL
|
||||
// selector (per CSS, :hover counts as a class).
|
||||
const isHover = /:hover\b/i.test(selector);
|
||||
let matchSelector = null;
|
||||
if (isHover) {
|
||||
matchSelector = selector.replace(/:hover\b/gi, '').trim();
|
||||
if (!matchSelector || /[>+~]\s*$/.test(matchSelector)) matchSelector = null;
|
||||
else matchSelector = matchSelector.replace(/(^|[\s>+~])(?=$|[\s>+~])/g, '$1*');
|
||||
}
|
||||
rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++, isHover, matchSelector });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -803,6 +860,13 @@ class StaticDocument {
|
||||
this.domutils = modules.domutils;
|
||||
this._wrappers = new WeakMap();
|
||||
this._styleMap = new WeakMap();
|
||||
this._hoverStyleMap = new WeakMap();
|
||||
this._accentDashPseudo = new WeakSet();
|
||||
// Elements whose ::before/::after paints a full-cover opaque surface
|
||||
// (position absolute/fixed + inset 0 + solid background). The pseudo is
|
||||
// the element's visible background for contrast purposes even though it
|
||||
// never joins the element cascade.
|
||||
this._pseudoSurface = new WeakMap();
|
||||
}
|
||||
wrap(node) {
|
||||
let wrapped = this._wrappers.get(node);
|
||||
@@ -839,6 +903,24 @@ class StaticDocument {
|
||||
getStyle(el) {
|
||||
return this._styleMap.get(el.node) || makeStaticStyle();
|
||||
}
|
||||
setHoverStyle(node, style) {
|
||||
this._hoverStyleMap.set(node, style);
|
||||
}
|
||||
getHoverStyle(el) {
|
||||
return this._hoverStyleMap.get(el.node) || null;
|
||||
}
|
||||
setAccentDashPseudo(node) {
|
||||
this._accentDashPseudo.add(node);
|
||||
}
|
||||
hasAccentDashPseudo(el) {
|
||||
return this._accentDashPseudo.has(el.node);
|
||||
}
|
||||
setPseudoSurface(node, color) {
|
||||
this._pseudoSurface.set(node, color);
|
||||
}
|
||||
getPseudoSurface(el) {
|
||||
return this._pseudoSurface.get(el.node) || null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeStaticStyle(values = {}) {
|
||||
@@ -854,6 +936,9 @@ function buildStaticWindow(staticDoc) {
|
||||
return {
|
||||
document: staticDoc,
|
||||
getComputedStyle: (el) => staticDoc.getStyle(el),
|
||||
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
|
||||
hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
|
||||
getPseudoSurface: (el) => staticDoc.getPseudoSurface(el),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -867,7 +952,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
const rel = link.attribs?.rel || '';
|
||||
const href = link.attribs?.href || '';
|
||||
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
|
||||
const cssPath = path.resolve(fileDir, href);
|
||||
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
|
||||
// literal path with the query in it; a versioned link otherwise made the
|
||||
// whole stylesheet invisible to every element-level check.
|
||||
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
|
||||
try {
|
||||
const css = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -884,6 +972,13 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
|
||||
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
|
||||
const specified = new Map();
|
||||
// Declarations from :hover rules, matched via their state-stripped
|
||||
// selectors. Merged per-property against the resting cascade in
|
||||
// computeNode — a hover declaration only takes effect if it would win
|
||||
// the cascade while the element is hovered (all resting rules still
|
||||
// apply in that state).
|
||||
const hoverSpecified = new Map();
|
||||
const rootCustomProps = collectCssCustomProps(cssText);
|
||||
const allNodes = modules.selectAll('*', root.children || []);
|
||||
const rules = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -899,9 +994,65 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
target: filePath,
|
||||
}, () => {
|
||||
for (const rule of rules) {
|
||||
// ::before/::after rules can't join the element cascade (pseudo
|
||||
// elements aren't DOM nodes), but one shape matters to the eyebrow
|
||||
// check: the short chromatic "kicker dash" (content box 8-80px wide,
|
||||
// 1-6px tall, accent-colored fill). Mark the base-selector matches
|
||||
// so checkElementHeroEyebrow can see the dash.
|
||||
if (!rule.isHover) {
|
||||
const pm = rule.selector.match(/^(.+?)\s*::?(?:before|after)$/i);
|
||||
if (pm) {
|
||||
const decls = new Map();
|
||||
for (const d of rule.declarations) decls.set(d.prop.toLowerCase(), d.value);
|
||||
const w = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', rootCustomProps));
|
||||
const h = cssLengthToPx(resolveVarRefs(decls.get('height') || decls.get('block-size') || '', rootCustomProps));
|
||||
if (w != null && h != null && w >= 8 && w <= 80 && h >= 1 && h <= 6) {
|
||||
const bgRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
|
||||
const token = bgRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
|
||||
const c = parseAnyColor(token ? token[0] : bgRaw);
|
||||
if (c && (c.a ?? 1) >= 0.1 && Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= 30) {
|
||||
try {
|
||||
for (const node of modules.selectAll(pm[1], root.children || [])) {
|
||||
staticDoc.setAccentDashPseudo(node);
|
||||
}
|
||||
} catch { /* unsupported base selector */ }
|
||||
}
|
||||
}
|
||||
// Full-cover surface pseudo: the CTA construction where the
|
||||
// element itself stays transparent and a ::before/::after with
|
||||
// position absolute/fixed + inset 0 (or all four sides 0, or
|
||||
// 100% width and height) plus an opaque background paints the
|
||||
// visible surface. Mark base-selector matches so the contrast
|
||||
// checks measure text against the surface the browser renders.
|
||||
const pseudoPos = String(decls.get('position') || '').toLowerCase();
|
||||
if (pseudoPos === 'absolute' || pseudoPos === 'fixed') {
|
||||
const zeroLen = v => v != null && /^0(?:px)?$/.test(String(v).trim());
|
||||
const insetRaw = String(decls.get('inset') || '').trim();
|
||||
const coversBox = (insetRaw !== '' && insetRaw.split(/\s+/).every(t => /^0(?:px)?$/.test(t)))
|
||||
|| ['top', 'right', 'bottom', 'left'].every(side => zeroLen(decls.get(side)))
|
||||
|| (String(decls.get('width') || '').trim() === '100%'
|
||||
&& String(decls.get('height') || '').trim() === '100%');
|
||||
if (coversBox && decls.has('content')) {
|
||||
const surfRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
|
||||
const surfToken = surfRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
|
||||
const surf = parseAnyColor(surfToken ? surfToken[0] : surfRaw);
|
||||
if (surf && (surf.a ?? 1) >= 0.9 && !/gradient/i.test(surfRaw)) {
|
||||
try {
|
||||
for (const node of modules.selectAll(pm[1], root.children || [])) {
|
||||
staticDoc.setPseudoSurface(node, surf);
|
||||
}
|
||||
} catch { /* unsupported base selector */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const matchSelector = rule.isHover ? rule.matchSelector : rule.selector;
|
||||
if (!matchSelector) continue;
|
||||
let matched;
|
||||
try {
|
||||
matched = modules.selectAll(rule.selector, root.children || []);
|
||||
matched = modules.selectAll(matchSelector, root.children || []);
|
||||
} catch {
|
||||
recordProfileEvent(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -910,13 +1061,13 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
target: filePath,
|
||||
ms: 0,
|
||||
findings: 0,
|
||||
detail: rule.selector,
|
||||
detail: matchSelector,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const node of matched) {
|
||||
for (const decl of rule.declarations) {
|
||||
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
|
||||
applyStaticDeclaration(rule.isHover ? hoverSpecified : specified, node, decl.prop, decl.value, {
|
||||
important: decl.important,
|
||||
specificity: rule.specificity,
|
||||
order: rule.order,
|
||||
@@ -959,6 +1110,28 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
}
|
||||
const style = makeStaticStyle(values);
|
||||
staticDoc.setStyle(node, style);
|
||||
|
||||
// Hover pass: limited to the two properties the hover-contrast check
|
||||
// consumes. A hover declaration wins only if it beats the resting
|
||||
// winner for that property under normal cascade rules (specificity /
|
||||
// order / importance) — exactly what a browser computes while the
|
||||
// element is hovered.
|
||||
const hoverMap = hoverSpecified.get(node);
|
||||
if (hoverMap) {
|
||||
let hoverValues = null;
|
||||
for (const prop of ['color', 'backgroundColor']) {
|
||||
const hoverDecl = hoverMap.get(prop);
|
||||
if (!hoverDecl) continue;
|
||||
const restingDecl = specifiedMap.get(prop);
|
||||
if (!compareStaticPriority(restingDecl, hoverDecl)) continue;
|
||||
const next = normalizeStaticCssValue(prop, hoverDecl.value, customProps, parentStyle, values);
|
||||
if (next === values[prop]) continue;
|
||||
if (!hoverValues) hoverValues = { ...values };
|
||||
hoverValues[prop] = next;
|
||||
}
|
||||
if (hoverValues) staticDoc.setHoverStyle(node, makeStaticStyle(hoverValues));
|
||||
}
|
||||
|
||||
for (const child of node.children || []) {
|
||||
if (child.type === 'tag') computeNode(child, style, customProps);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -12,20 +18,23 @@ import {
|
||||
checkElementGlow,
|
||||
checkElementGptBorderShadow,
|
||||
checkElementHeroEyebrow,
|
||||
checkElementHoverContrast,
|
||||
checkElementIconTile,
|
||||
checkElementItalicSerif,
|
||||
checkElementMotion,
|
||||
checkElementOversizedH1,
|
||||
checkElementQuality,
|
||||
checkElementRadialSpotlight,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
checkRepeatedSectionKickersFromDoc,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
resolveBackground,
|
||||
resolveBorderRadiusPx,
|
||||
} from '../../rules/checks.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
|
||||
import {
|
||||
StaticDocument,
|
||||
@@ -51,9 +60,6 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const font of overusedFound) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
||||
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
|
||||
}
|
||||
const sizes = new Set();
|
||||
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
|
||||
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
|
||||
@@ -84,8 +90,9 @@ function checkElementBrokenImage(el) {
|
||||
}
|
||||
|
||||
const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
|
||||
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window), el) },
|
||||
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
|
||||
{ id: 'hover-color-rules', selector: '*', run: (el, tag, style, window) => checkElementHoverContrast(el, style, tag, window) },
|
||||
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
|
||||
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
|
||||
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
|
||||
@@ -96,6 +103,7 @@ const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
|
||||
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
|
||||
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
|
||||
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
|
||||
];
|
||||
|
||||
async function detectHtml(filePath, options = {}) {
|
||||
@@ -168,6 +176,22 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
const sourceDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
|
||||
const staticDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'page',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
|
||||
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
|
||||
}
|
||||
|
||||
if (isFullPage(html)) {
|
||||
const runPageCheck = (ruleId, callback) => profile
|
||||
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
|
||||
@@ -175,7 +199,13 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
|
||||
for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('repeated-container-text', () => checkRepeatedContainerTextFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
|
||||
@@ -187,10 +217,33 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
|
||||
// Scoped corpora for the pattern checks (see buildHtmlPatternCorpora in
|
||||
// rules/checks.mjs): CSS-property regexes must not fire on prose ABOUT
|
||||
// css — `<code>background-clip: text</code>` in a changelog is
|
||||
// documentation, not styling. cssText already carries the <style>
|
||||
// blocks and any linked local stylesheets; style/class attributes come
|
||||
// from the parsed document, so escaped code samples never contribute.
|
||||
const styleAttrParts = [];
|
||||
const classAttrParts = [];
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
const styleAttr = el.getAttribute('style');
|
||||
if (styleAttr) styleAttrParts.push(`style="${styleAttr}"`);
|
||||
const classAttr = el.getAttribute('class');
|
||||
if (classAttr) classAttrParts.push(classAttr);
|
||||
}
|
||||
const patternCorpora = {
|
||||
styleText: [cssText, ...styleAttrParts].join('\n'),
|
||||
classText: classAttrParts.join('\n'),
|
||||
};
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
// overrides the registry default.
|
||||
if (f.severity) item.severity = f.severity;
|
||||
findings.push(item);
|
||||
}
|
||||
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
||||
// numbered section markers, aphoristic cadence) live in the regex
|
||||
@@ -202,7 +255,10 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? findings : applyInlineIgnores(findings, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -6,7 +6,13 @@ function getAP(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 };
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -5,11 +5,24 @@ import path from 'node:path';
|
||||
// File walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hidden directories are skipped wholesale during recursion (below), which
|
||||
// covers .git / .next / .nuxt / .svelte-kit / .turbo / .vercel and — the
|
||||
// issue #303 class — every vendored AI-harness install (.claude, .cursor,
|
||||
// .codex, .agents, .impeccable, ...) whose bundled detector source would
|
||||
// otherwise be reported as findings on a root scan. Only the non-hidden
|
||||
// build/dependency dirs need naming. An explicitly passed hidden target
|
||||
// still scans: walkDir name-checks children, never the root it's given.
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
|
||||
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
|
||||
'node_modules', 'dist', 'build', '__pycache__',
|
||||
]);
|
||||
|
||||
// The exceptions to the hidden-dir rule: hidden directories that
|
||||
// conventionally hold real UI source rather than tooling or vendored code.
|
||||
// VitePress and VuePress keep custom theme components in
|
||||
// .vitepress/theme/*.vue / .vuepress/theme/, and Storybook keeps preview
|
||||
// decorators/styles in .storybook/.
|
||||
const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
@@ -24,6 +37,7 @@ function walkDir(dir) {
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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);
|
||||
|
||||
@@ -21,24 +21,17 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'overused fonts like Inter',
|
||||
},
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
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',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -75,6 +68,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -84,6 +78,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -99,18 +94,73 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'bounce or elastic easing',
|
||||
},
|
||||
{
|
||||
id: 'pulsing-dot',
|
||||
category: 'slop',
|
||||
name: 'Pulsing status dot',
|
||||
description:
|
||||
'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'decorative pulsing status dot',
|
||||
},
|
||||
{
|
||||
id: 'blinking-cursor',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Decorative blinking cursor',
|
||||
description:
|
||||
'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
|
||||
skillSection: 'Motion',
|
||||
},
|
||||
{
|
||||
id: 'shape-assembled-illustration',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Shape-assembled illustration',
|
||||
description:
|
||||
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
|
||||
skillSection: 'Imagery',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow',
|
||||
category: 'slop',
|
||||
name: 'Dark mode with glowing accents',
|
||||
name: 'Glowing shadow 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.',
|
||||
'Colored glow shadows — a zero-offset chromatic halo (box- or text-shadow) on any background, or any colored blurred shadow on a dark background — are the default "cool" look of AI-generated UIs. Use neutral elevation shadows and subtle, purposeful lighting instead.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-halo',
|
||||
category: 'slop',
|
||||
name: 'Radial-gradient background halo',
|
||||
description:
|
||||
'A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-spotlight-glow',
|
||||
category: 'slop',
|
||||
name: 'Decorative radial spotlight glow',
|
||||
description:
|
||||
'A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a "spotlight." It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'marquee',
|
||||
category: 'slop',
|
||||
name: 'Auto-scrolling marquee',
|
||||
description:
|
||||
'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.',
|
||||
skillSection: 'Motion',
|
||||
skillGuideline: 'auto-scrolling marquee',
|
||||
},
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -120,6 +170,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -129,6 +180,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -136,31 +188,37 @@ const ANTIPATTERNS = [
|
||||
skillGuideline: 'tiny uppercase tracked label above the hero headline',
|
||||
},
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
id: 'kicker-above-heading',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
scopes: ['type'],
|
||||
name: 'Kicker / eyebrow label above heading',
|
||||
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.',
|
||||
'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||||
skillGuideline: 'kicker or eyebrow labels above headings',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
id: 'numbered-section-labels',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
name: 'Tiny numbered section labels',
|
||||
description:
|
||||
'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',
|
||||
'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'numbered section markers',
|
||||
},
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -185,6 +243,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -194,6 +253,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -211,6 +271,49 @@ const ANTIPATTERNS = [
|
||||
},
|
||||
|
||||
// ── Quality: general design and accessibility issues ──
|
||||
{
|
||||
id: 'script-error',
|
||||
category: 'quality',
|
||||
severity: 'error',
|
||||
name: 'Uncaught script error on load',
|
||||
description:
|
||||
'A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.',
|
||||
},
|
||||
{
|
||||
id: 'content-hidden-at-rest',
|
||||
category: 'quality',
|
||||
severity: 'error',
|
||||
scopes: ['layout'],
|
||||
name: 'Content invisible at rest',
|
||||
description:
|
||||
'A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.',
|
||||
},
|
||||
{
|
||||
id: 'edge-flush-cards',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cards flush against the scroller edge',
|
||||
description:
|
||||
'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.',
|
||||
},
|
||||
{
|
||||
id: 'text-occlusion',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Text occluded by an overlapping element',
|
||||
description:
|
||||
'Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.',
|
||||
skillSection: 'Layout & Space',
|
||||
},
|
||||
{
|
||||
id: 'first-viewport-column-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'One column stretches the first viewport',
|
||||
description:
|
||||
'A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.',
|
||||
skillSection: 'Layout & Space',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
category: 'quality',
|
||||
@@ -239,6 +342,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -248,6 +352,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -257,6 +362,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -264,6 +370,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -271,13 +378,24 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
},
|
||||
{
|
||||
id: 'heading-rhythm',
|
||||
category: 'quality',
|
||||
scopes: ['layout', 'type'],
|
||||
name: 'Heading crowded against the previous block',
|
||||
description:
|
||||
'A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.',
|
||||
skillSection: 'Layout & Space',
|
||||
},
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -285,13 +403,23 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Undersized functional text',
|
||||
description:
|
||||
'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -301,6 +429,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -308,28 +437,77 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'content wider than its container',
|
||||
},
|
||||
{
|
||||
id: 'repeated-container-text',
|
||||
category: 'quality',
|
||||
name: 'Same text repeated inside one container',
|
||||
description:
|
||||
'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.',
|
||||
},
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'overflow container clipping positioned children',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font family outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-color',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Color outside DESIGN.md',
|
||||
description:
|
||||
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'literal color outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-radius',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
name: 'Radius outside DESIGN.md',
|
||||
description:
|
||||
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
// ── Common generated-UI tells ───────────────────────────────────────────
|
||||
{
|
||||
id: 'gpt-thin-border-wide-shadow',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Hairline border with wide shadow',
|
||||
description:
|
||||
'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
|
||||
@@ -340,18 +518,26 @@ const ANTIPATTERNS = [
|
||||
id: 'repeating-stripes-gradient',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Repeating-gradient stripes',
|
||||
description:
|
||||
'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'repeating-gradient decorative stripes',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Theater framing copy',
|
||||
description:
|
||||
'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
|
||||
@@ -362,7 +548,6 @@ const ANTIPATTERNS = [
|
||||
id: 'image-hover-transform',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gemini',
|
||||
name: 'Image hover transform',
|
||||
description:
|
||||
'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
|
||||
@@ -382,6 +567,18 @@ function getAntipattern(id) {
|
||||
return ANTIPATTERNS.find(rule => rule.id === id);
|
||||
}
|
||||
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
return ADVISORY_RULE_IDS.has(id);
|
||||
}
|
||||
|
||||
function getRulesForCategory(category) {
|
||||
return ANTIPATTERNS.filter(rule => rule.category === category);
|
||||
}
|
||||
@@ -390,30 +587,31 @@ function getRuleEngineSupport(engine) {
|
||||
return RULE_ENGINE_SUPPORT[engine] || new Set();
|
||||
}
|
||||
|
||||
// Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini').
|
||||
const GATED_PROVIDERS = new Set(
|
||||
ANTIPATTERNS.map(rule => rule.gated).filter(Boolean),
|
||||
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
|
||||
// CLI --scope flag to narrow output to one design domain.
|
||||
const RULE_SCOPES = new Set(
|
||||
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
|
||||
);
|
||||
|
||||
// Drop findings for rules gated behind a provider tag unless that provider
|
||||
// was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always
|
||||
// pass through. `findings` carry the rule id on `.antipattern`.
|
||||
function filterByProviders(findings, providers = []) {
|
||||
const enabled = new Set(providers || []);
|
||||
if (!GATED_PROVIDERS.size) return findings;
|
||||
// Keep only findings whose rule declares at least one of the requested
|
||||
// scopes. An empty scope list means no filtering (default CLI behavior).
|
||||
function filterByScopes(findings, scopes = []) {
|
||||
if (!scopes || scopes.length === 0) return findings;
|
||||
const enabled = new Set(scopes);
|
||||
return findings.filter(f => {
|
||||
const rule = getAntipattern(f.antipattern);
|
||||
if (!rule || !rule.gated) return true;
|
||||
return enabled.has(rule.gated);
|
||||
return (rule?.scopes || []).some(scope => enabled.has(scope));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
GATED_PROVIDERS,
|
||||
ADVISORY_RULE_IDS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
filterByProviders,
|
||||
isAdvisoryRule,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -97,5 +106,7 @@ export {
|
||||
GENERIC_FONTS,
|
||||
WCAG_LARGE_TEXT_PX,
|
||||
WCAG_LARGE_BOLD_TEXT_PX,
|
||||
EM_DASH_FLOOR,
|
||||
EM_DASH_CHARS_PER_DASH,
|
||||
KNOWN_SERIF_FONTS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
export { extractGoogleFontFamilies };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
Reference in New Issue
Block a user