mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 01:26:29 +03:00
[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:
@@ -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);
|
||||
|
||||
+31
-7
@@ -1,9 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.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,
|
||||
@@ -79,10 +85,17 @@ 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
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--help Show this help message
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
@@ -93,7 +106,8 @@ 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() {
|
||||
@@ -114,10 +128,16 @@ async function detectCli() {
|
||||
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
|
||||
);
|
||||
}
|
||||
const configEnabled = !args.includes('--no-config');
|
||||
const detectionConfig = configEnabled
|
||||
? readDetectionConfig(process.cwd())
|
||||
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
|
||||
const providers = [];
|
||||
if (args.includes('--gpt')) providers.push('gpt');
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const scanOptions = { providers };
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -175,7 +195,8 @@ 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)
|
||||
@@ -219,6 +240,7 @@ async function detectCli() {
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(resolved, scanOptions));
|
||||
@@ -232,6 +254,8 @@ async function detectCli() {
|
||||
}
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
import fs from 'node:fs';
|
||||
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'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
|
||||
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 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 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: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(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;
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)}"` : '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
};
|
||||
@@ -425,6 +425,35 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'overflow container clipping positioned children',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
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',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -4394,6 +4423,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 : '',
|
||||
};
|
||||
@@ -4430,10 +4460,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
|
||||
@@ -4464,6 +4687,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);
|
||||
@@ -4480,6 +4704,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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -7,6 +7,25 @@ import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||
|
||||
function serializeDesignSystemForBrowser(designSystem) {
|
||||
if (!designSystem?.present) return null;
|
||||
return {
|
||||
present: true,
|
||||
hasFonts: designSystem.hasFonts === true,
|
||||
allowedFonts: Array.from(designSystem.allowedFonts || []),
|
||||
hasColors: designSystem.hasColors === true,
|
||||
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
|
||||
.map(entry => entry?.color)
|
||||
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
||||
.map(color => ({ r: color.r, g: color.g, b: color.b })),
|
||||
hasRadii: designSystem.hasRadii === true,
|
||||
allowedRadii: (designSystem.allowedRadii || [])
|
||||
.map(entry => Number(entry?.px))
|
||||
.filter(px => Number.isFinite(px)),
|
||||
hasPillRadius: designSystem.hasPillRadius === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
|
||||
if (options?.visualContrast === false) return [];
|
||||
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
|
||||
@@ -163,17 +182,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,7 +213,7 @@ 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 || '' }))
|
||||
);
|
||||
});
|
||||
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
|
||||
@@ -213,7 +234,11 @@ async function detectUrl(url, options = {}) {
|
||||
}, () => browser.close());
|
||||
}
|
||||
}
|
||||
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
|
||||
return filterByProviders(results.map(f => {
|
||||
const item = finding(f.id, url, f.snippet);
|
||||
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
|
||||
return item;
|
||||
}), options.providers);
|
||||
}
|
||||
|
||||
async function createBrowserDetector(options = {}) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
@@ -503,6 +504,15 @@ function detectText(content, filePath, options = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
|
||||
}
|
||||
|
||||
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
||||
const deduped = [];
|
||||
for (const f of findings) {
|
||||
|
||||
@@ -272,6 +272,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginBottom: '0px',
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -326,6 +327,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-bottom': 'marginBottom',
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
|
||||
@@ -2,6 +2,11 @@ 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 { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
@@ -168,6 +173,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)
|
||||
|
||||
@@ -323,6 +323,35 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'overflow container clipping positioned children',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
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',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user