diff --git a/README.md b/README.md index b95bb0bd1..9585758b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Impeccable -Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 45 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 46 deterministic detector rules for AI-generated frontend design. > **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style). @@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th Impeccable adds: - **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components. - **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. -- **45 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. +- **46 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. ## What's Included @@ -357,7 +357,7 @@ npx impeccable ignores add-file "src/legacy/**" npx impeccable ignores add-value overused-font Inter --reason "Brand font" ``` -The detector catches 45 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more). +The detector catches 46 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more). By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution. diff --git a/README.npm.md b/README.npm.md index 6255bb353..70d418ca5 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,6 +1,6 @@ # Impeccable CLI -Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 45 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. +Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 46 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. ## Quick Start @@ -56,7 +56,7 @@ npx impeccable detect --fast src/ **Quality**: tiny body text, cramped padding, long line lengths, small touch targets -45 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). +46 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). ## Exit Codes diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index f70027bc8..69a61e775 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/cli/main.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadDesignSystemForCwd } 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'; @@ -93,6 +94,8 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) + --scope Only report rules in the given design domain + (type, layout). Comma-separated. --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 @@ -151,6 +154,33 @@ async function detectCli() { const providers = []; if (args.includes('--gpt')) providers.push('gpt'); if (args.includes('--gemini')) providers.push('gemini'); + 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; + } + 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; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; // Inline `impeccable-disable*` waivers are part of the scanned file, so they @@ -276,6 +306,7 @@ async function detectCli() { } allFindings = filterDetectionFindings(allFindings, detectionConfig); + allFindings = filterByScopes(allFindings, scopes); if (allFindings.length > 0) { if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 352148d86..fdbf74e69 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -9,6 +9,8 @@ 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 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; @@ -16,6 +18,9 @@ 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) { @@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) { } } +function addTypographySizes(out, typography) { + if (!typography || typeof typography !== 'object') return; + for (const role of Object.values(typography)) { + if (!role || typeof role !== 'object') continue; + const raw = String(role.fontSize ?? '').trim().toLowerCase(); + if (!FONT_SIZE_LITERAL_RE.test(raw)) continue; + const px = resolveLengthPx(raw, 16); + if (px == null || !Number.isFinite(px) || px <= 0) continue; + out.allowedFontSizes.push({ value: raw, px }); + } +} + function addRoundedScale(out, rounded) { if (!rounded || typeof rounded !== 'object') return; for (const [rawName, value] of Object.entries(rounded)) { @@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) { 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); @@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) { out.hasFonts = out.allowedFonts.size > 0; out.hasColors = out.allowedColorKeys.size > 0; out.hasRadii = out.allowedRadii.length > 0; + out.hasFontSizes = out.allowedFontSizes.length > 0; return out; } @@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) { return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX); } +function isAllowedFontSizeRaw(raw, designSystem) { + if (!designSystem?.hasFontSizes) return true; + const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, ''); + if (!FONT_SIZE_LITERAL_RE.test(text)) return true; + const px = resolveLengthPx(text, 16); + if (px == null || !Number.isFinite(px) || px <= 0) return true; + return designSystem.allowedFontSizes.some( + entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX, + ); +} + function lineLooksCommented(line) { const trimmed = String(line || '').trim(); return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('