diff --git a/README.md b/README.md index 2b18b4c4f..c3dec5a46 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 58 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 60 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. -- **58 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. +- **60 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 @@ -377,7 +377,7 @@ npx impeccable ignores add-file "src/legacy/**" npx impeccable ignores add-value overused-font Inter --reason "Brand font" ``` -The detector catches 49 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 60 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 ddb4deb65..5aa7077e1 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 58 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 60 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 -58 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). +60 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). ## Exit Codes diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 8d18d8a3a..e61c536ee 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1490,6 +1490,7 @@ if (IS_BROWSER) { ...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 })), diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 0fdc460ff..d07caa7ab 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -260,6 +260,15 @@ const ANTIPATTERNS = [ 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', @@ -3559,6 +3568,131 @@ function checkElementAIPaletteDOM(el) { return findings; } +// ─── Decorative radial spotlight glow ─────────────────────────────────────── +// A soft, low-opacity chromatic radial-gradient fading to transparent, painted +// as a decorative wash behind a hero or section. The translucent sibling of the +// `radial-halo` tell: `radial-halo` requires a saturated, near-opaque center on +// a dark page; this catches the low-alpha "spotlight" the halo gate lets slip +// (e.g. `radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26), +// transparent 44%)`). The two alpha bands are disjoint, so they never +// double-report the same declaration. +const SPOTLIGHT_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i; + +// Parse the FIRST non-repeating radial-gradient in a background value into its +// ordered color stops. Each stop is { color: {r,g,b,a} | null, transparent }. +// Returns null when there is no plain radial-gradient to read. +function parseRadialGradientStops(value) { + if (!value || !/radial-gradient/i.test(value)) return null; + const gradRe = /(repeating-)?radial-gradient\(/gi; + let g; + while ((g = gradRe.exec(value)) !== null) { + if (g[1]) continue; // repeating-* is a pattern, not a spotlight + let depth = 0, end = -1; + const open = value.indexOf('(', g.index); + for (let i = open; i < value.length; i++) { + if (value[i] === '(') depth++; + else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } } + } + if (end < 0) return null; + const args = splitTopLevelCommas(value.slice(open + 1, end)); + // The optional prelude (shape / size / `at `) carries no color token. + const stopArgs = args.filter(a => SPOTLIGHT_COLOR_TOKEN_RE.test(a)); + if (stopArgs.length < 2) return null; + return stopArgs.map(a => { + const tok = a.match(SPOTLIGHT_COLOR_TOKEN_RE); + if (!tok) return { color: null, transparent: false }; + if (/^transparent$/i.test(tok[0])) return { color: null, transparent: true }; + const color = parseAnyColor(tok[0]); + return { color, transparent: !!color && (color.a ?? 1) <= 0.05 }; + }); + } + return null; +} + +// Pure gate. `label` is a stable identifier the fixture test keys on. +function checkRadialSpotlight({ gradientValue, width, height, label }) { + const stops = parseRadialGradientStops(gradientValue); + if (!stops || stops.length < 2) return []; + + // Must fade OUT: the last stop is transparent / near-zero alpha. A gradient + // between two visible surfaces is a real background, not a floating glow. + const last = stops[stops.length - 1]; + const lastAlpha = last.transparent ? 0 : (last.color ? (last.color.a ?? 1) : 1); + if (lastAlpha > 0.05) return []; + + // The visible (non-transparent, parseable) color stops. + const colored = stops.filter(s => !s.transparent && s.color && (s.color.a ?? 1) > 0.05); + if (colored.length === 0) return []; + // One soft glow, not a multi-color composition: at most two visible stops. + if (colored.length > 2) return []; + // Every visible stop must be LOW opacity. Any opaque stop means a real fill + // or a saturated halo (`radial-halo`'s job), not this translucent spotlight. + if (colored.some(s => (s.color.a ?? 1) >= 0.45)) return []; + // At least one visible stop must be chromatic. A neutral (grayscale) + // near-black / near-white vignette is a legitimate lighting move, exempt. + const chromatic = colored.find(s => hasChroma(s.color, 24)); + if (!chromatic) return []; + + // Decorative-scale gate. Badges, avatars, and actual small "lights" are + // exempt; a spotlight glow only reads as slop when it washes a large surface. + if (!(width >= 240 && height >= 160)) return []; + + const alpha = (chromatic.color.a ?? 1).toFixed(2); + const name = label || 'section'; + return [{ + id: 'radial-spotlight-glow', + snippet: `radial-gradient spotlight glow "${name}" (${colorToHex(chromatic.color)} a${alpha} → transparent) on ${Math.round(width)}x${Math.round(height)} surface`, + }]; +} + +// Read the raw radial-gradient source off an element's computed style, with a +// fallback to the `background` shorthand and the inline style attribute for +// engines that don't decompose the shorthand into backgroundImage. +function elementGradientValue(style, el) { + const bgImage = style.backgroundImage && style.backgroundImage !== 'none' ? style.backgroundImage : ''; + if (/radial-gradient/i.test(bgImage)) return bgImage; + const bg = style.background || ''; + if (/radial-gradient/i.test(bg)) return bg; + const rawStyle = el?.getAttribute?.('style') || ''; + const m = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); + if (m && /radial-gradient/i.test(m[1])) return m[1]; + return ''; +} + +function spotlightLabel(el) { + const dataName = el.getAttribute?.('data-name'); + if (dataName) return dataName; + if (typeof el.id === 'string' && el.id) return el.id; + const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : ''; + if (cls) return cls; + return el.tagName ? el.tagName.toLowerCase() : 'section'; +} + +function checkElementRadialSpotlightDOM(el) { + const style = getComputedStyle(el); + const gradientValue = elementGradientValue(style, el); + if (!gradientValue) return []; + const rect = el.getBoundingClientRect(); + return checkRadialSpotlight({ + gradientValue, + width: rect.width, + height: rect.height, + label: spotlightLabel(el), + }); +} + +function checkElementRadialSpotlight(el, style, tag, window) { + const gradientValue = elementGradientValue(style, el); + if (!gradientValue) return []; + // Static engine does no layout — read explicit pixel dimensions from CSS. + return checkRadialSpotlight({ + gradientValue, + width: parseFloat(style.width) || 0, + height: parseFloat(style.height) || 0, + label: spotlightLabel(el), + }); +} + const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']); // Resolve a CSS font-size value to pixels by walking up the parent chain. @@ -7529,6 +7663,7 @@ if (IS_BROWSER) { ...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 })), diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index bcba026be..1ebb3e427 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -24,6 +24,7 @@ import { checkElementMotion, checkElementOversizedH1, checkElementQuality, + checkElementRadialSpotlight, checkCreamPalette, checkHtmlPatterns, checkNumberedSectionLabelsFromDoc, @@ -105,6 +106,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 = {}) { diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 2fb79744f..c47d39524 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -149,6 +149,15 @@ const ANTIPATTERNS = [ 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', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index e2aacdc2e..418aa8f45 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -2766,6 +2766,131 @@ function checkElementAIPaletteDOM(el) { return findings; } +// ─── Decorative radial spotlight glow ─────────────────────────────────────── +// A soft, low-opacity chromatic radial-gradient fading to transparent, painted +// as a decorative wash behind a hero or section. The translucent sibling of the +// `radial-halo` tell: `radial-halo` requires a saturated, near-opaque center on +// a dark page; this catches the low-alpha "spotlight" the halo gate lets slip +// (e.g. `radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26), +// transparent 44%)`). The two alpha bands are disjoint, so they never +// double-report the same declaration. +const SPOTLIGHT_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i; + +// Parse the FIRST non-repeating radial-gradient in a background value into its +// ordered color stops. Each stop is { color: {r,g,b,a} | null, transparent }. +// Returns null when there is no plain radial-gradient to read. +function parseRadialGradientStops(value) { + if (!value || !/radial-gradient/i.test(value)) return null; + const gradRe = /(repeating-)?radial-gradient\(/gi; + let g; + while ((g = gradRe.exec(value)) !== null) { + if (g[1]) continue; // repeating-* is a pattern, not a spotlight + let depth = 0, end = -1; + const open = value.indexOf('(', g.index); + for (let i = open; i < value.length; i++) { + if (value[i] === '(') depth++; + else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } } + } + if (end < 0) return null; + const args = splitTopLevelCommas(value.slice(open + 1, end)); + // The optional prelude (shape / size / `at `) carries no color token. + const stopArgs = args.filter(a => SPOTLIGHT_COLOR_TOKEN_RE.test(a)); + if (stopArgs.length < 2) return null; + return stopArgs.map(a => { + const tok = a.match(SPOTLIGHT_COLOR_TOKEN_RE); + if (!tok) return { color: null, transparent: false }; + if (/^transparent$/i.test(tok[0])) return { color: null, transparent: true }; + const color = parseAnyColor(tok[0]); + return { color, transparent: !!color && (color.a ?? 1) <= 0.05 }; + }); + } + return null; +} + +// Pure gate. `label` is a stable identifier the fixture test keys on. +function checkRadialSpotlight({ gradientValue, width, height, label }) { + const stops = parseRadialGradientStops(gradientValue); + if (!stops || stops.length < 2) return []; + + // Must fade OUT: the last stop is transparent / near-zero alpha. A gradient + // between two visible surfaces is a real background, not a floating glow. + const last = stops[stops.length - 1]; + const lastAlpha = last.transparent ? 0 : (last.color ? (last.color.a ?? 1) : 1); + if (lastAlpha > 0.05) return []; + + // The visible (non-transparent, parseable) color stops. + const colored = stops.filter(s => !s.transparent && s.color && (s.color.a ?? 1) > 0.05); + if (colored.length === 0) return []; + // One soft glow, not a multi-color composition: at most two visible stops. + if (colored.length > 2) return []; + // Every visible stop must be LOW opacity. Any opaque stop means a real fill + // or a saturated halo (`radial-halo`'s job), not this translucent spotlight. + if (colored.some(s => (s.color.a ?? 1) >= 0.45)) return []; + // At least one visible stop must be chromatic. A neutral (grayscale) + // near-black / near-white vignette is a legitimate lighting move, exempt. + const chromatic = colored.find(s => hasChroma(s.color, 24)); + if (!chromatic) return []; + + // Decorative-scale gate. Badges, avatars, and actual small "lights" are + // exempt; a spotlight glow only reads as slop when it washes a large surface. + if (!(width >= 240 && height >= 160)) return []; + + const alpha = (chromatic.color.a ?? 1).toFixed(2); + const name = label || 'section'; + return [{ + id: 'radial-spotlight-glow', + snippet: `radial-gradient spotlight glow "${name}" (${colorToHex(chromatic.color)} a${alpha} → transparent) on ${Math.round(width)}x${Math.round(height)} surface`, + }]; +} + +// Read the raw radial-gradient source off an element's computed style, with a +// fallback to the `background` shorthand and the inline style attribute for +// engines that don't decompose the shorthand into backgroundImage. +function elementGradientValue(style, el) { + const bgImage = style.backgroundImage && style.backgroundImage !== 'none' ? style.backgroundImage : ''; + if (/radial-gradient/i.test(bgImage)) return bgImage; + const bg = style.background || ''; + if (/radial-gradient/i.test(bg)) return bg; + const rawStyle = el?.getAttribute?.('style') || ''; + const m = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); + if (m && /radial-gradient/i.test(m[1])) return m[1]; + return ''; +} + +function spotlightLabel(el) { + const dataName = el.getAttribute?.('data-name'); + if (dataName) return dataName; + if (typeof el.id === 'string' && el.id) return el.id; + const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : ''; + if (cls) return cls; + return el.tagName ? el.tagName.toLowerCase() : 'section'; +} + +function checkElementRadialSpotlightDOM(el) { + const style = getComputedStyle(el); + const gradientValue = elementGradientValue(style, el); + if (!gradientValue) return []; + const rect = el.getBoundingClientRect(); + return checkRadialSpotlight({ + gradientValue, + width: rect.width, + height: rect.height, + label: spotlightLabel(el), + }); +} + +function checkElementRadialSpotlight(el, style, tag, window) { + const gradientValue = elementGradientValue(style, el); + if (!gradientValue) return []; + // Static engine does no layout — read explicit pixel dimensions from CSS. + return checkRadialSpotlight({ + gradientValue, + width: parseFloat(style.width) || 0, + height: parseFloat(style.height) || 0, + label: spotlightLabel(el), + }); +} + const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']); // Resolve a CSS font-size value to pixels by walking up the parent chain. @@ -5307,6 +5432,9 @@ export { checkElementMotionDOM, checkElementGlowDOM, checkElementAIPaletteDOM, + checkElementRadialSpotlightDOM, + checkElementRadialSpotlight, + checkRadialSpotlight, resolveFontSizePx, resolveLengthPx, checkQuality, diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 3e6b7d65c..e2ee09ac4 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -592,6 +592,47 @@ describe('detectHtml — icon-tile-stack', () => { }); }); +describe('detectHtml — radial-spotlight-glow', () => { + // Two-column fixture convention: left col = should-flag, right col = should-pass. + // The rule's snippet embeds the element's data-name in quotes, e.g. + // radial-gradient spotlight glow "Hero Spotlight Blue" (#506fff a0.26 → transparent). + const SHOULD_FLAG = [ + 'Hero Spotlight Blue', + 'Section Glow Violet', + 'Overlay Glow Cyan', + 'Two Stop Soft Glow', + 'Hex Alpha Glow', + ]; + const SHOULD_PASS = [ + 'Opaque Radial Background', + 'Small Accent Badge', + 'Avatar Glow Light', + 'Neutral Vignette', + 'White Vignette', + 'Rich Radial Composition', + 'Rich Transparent Composition', + 'Opaque Center Glow', + 'Linear Gradient Wash', + ]; + + it('radial-spotlight-glow: flags only the should-flag column', async () => { + const f = await detectHtml(path.join(FIXTURES, 'radial-spotlight-glow.html')); + const flagged = new Set(); + for (const r of f) { + if (r.antipattern !== 'radial-spotlight-glow') continue; + const m = (r.snippet || '').match(/"([^"]+)"/); + if (m) flagged.add(m[1]); + } + + for (const text of SHOULD_FLAG) { + assert.ok(flagged.has(text), `expected "${text}" to be flagged as radial-spotlight-glow`); + } + for (const text of SHOULD_PASS) { + assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as radial-spotlight-glow`); + } + }); +}); + describe('detectHtml — undersized-ui-text', () => { // Two-column fixture: left col = should-flag, right col = should-pass. // The rule's snippet embeds the element's direct text in quotes, e.g. diff --git a/tests/fixtures/antipatterns/radial-spotlight-glow.html b/tests/fixtures/antipatterns/radial-spotlight-glow.html new file mode 100644 index 000000000..6c820522c --- /dev/null +++ b/tests/fixtures/antipatterns/radial-spotlight-glow.html @@ -0,0 +1,112 @@ + + + + +radial-spotlight-glow fixture + + + +
+ +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ +