[codex] Add design-aware detector rules (#252)

* Add design-aware detector rules

* Fix design-aware detector noise

* Unify CLI and hook detector ignores

* Fix remaining design-system review findings

* Add detector ignore CLI

* Fix design detector review findings

* Fix design color source false positives

* Fix core test suite registration

* Add design-aware detector docs

* Fix font priority design-system parsing

* Fix color ignore value matching
This commit is contained in:
Paul Bakaus
2026-06-15 21:06:17 -07:00
committed by GitHub
parent c27a75ad41
commit 51d01e3a5f
80 changed files with 5372 additions and 527 deletions
+202
View File
@@ -1224,6 +1224,7 @@ if (IS_BROWSER) {
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
description: ap ? ap.description : '',
};
@@ -1260,10 +1261,203 @@ 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);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// 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
@@ -1294,6 +1488,7 @@ 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 })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
addBrowserFindings(groupMap, el, findings);
@@ -1310,6 +1505,13 @@ 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);