mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +03:00
Add radial-spotlight-glow detector rule
Flags the decorative low-opacity chromatic radial-gradient "spotlight" washed behind a hero or section and fading to transparent, an AI-slop reflex the saturated radial-halo gate lets slip (e.g. rgba(80,111,255, 0.26) -> transparent on a mobile hero). Gates: a non-repeating radial-gradient whose last stop is transparent, whose visible stops are all low-opacity (alpha < 0.45) with at most two of them, at least one chromatic (channel spread >= 24 exempts neutral vignettes), on a decorative-scale surface (width >= 240, height >= 160, exempting badges/avatars/small lights). The alpha band is disjoint from radial-halo (>= 0.7), so the two never double-report. Wired into both element loops (static-html + injected browser) with the pure checkRadialSpotlight shared by both adapters. TDD fixture with 5 flag / 9 pass shapes. Browser-path sweep over the eval corpus: 29 hits on 11 pages, 0 false positives. Count 59 -> 60. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e3f732e99c
commit
6ff9f957ac
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 <pos>`) 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 })),
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <pos>`) 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>radial-spotlight-glow fixture</title>
|
||||
<style>
|
||||
/* jsdom / static-html engine does no layout: every case declares explicit
|
||||
pixel width + height so the size gate can read them. */
|
||||
body { margin: 0; background: #0b0d13; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 24px; padding: 24px; }
|
||||
.case { color: #e6e6e6; }
|
||||
|
||||
/* ───────── SHOULD FLAG: decorative low-opacity chromatic spotlights ───────── */
|
||||
|
||||
/* The exact declaration that evaded the detector: soft blue spotlight fading
|
||||
to transparent, painted on a large mobile hero. */
|
||||
.flag-hero-blue {
|
||||
width: 360px; height: 560px;
|
||||
background: radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26), transparent 44%);
|
||||
}
|
||||
/* Violet accent wash on a full section. */
|
||||
.flag-section-violet {
|
||||
width: 900px; height: 600px;
|
||||
background-image: radial-gradient(circle, rgba(139,92,246,0.30), transparent 60%);
|
||||
}
|
||||
/* Cyan overlay-style glow (the pseudo-element overlay div reflex). */
|
||||
.flag-overlay-cyan {
|
||||
width: 760px; height: 480px;
|
||||
background: radial-gradient(ellipse at top, rgba(34,211,238,0.22), transparent 70%);
|
||||
}
|
||||
/* Single glow drawn with two low-alpha stops of one hue fading out. */
|
||||
.flag-two-stop-rose {
|
||||
width: 640px; height: 400px;
|
||||
background: radial-gradient(circle, rgba(255,90,120,0.28) 0%, rgba(255,90,120,0.12) 45%, transparent 75%);
|
||||
}
|
||||
/* Same tell written as an 8-digit hex (#506fff at alpha 0x3d ≈ 0.24). */
|
||||
.flag-hex-alpha {
|
||||
width: 700px; height: 460px;
|
||||
background: radial-gradient(circle, #506fff3d, transparent 50%);
|
||||
}
|
||||
|
||||
/* ───────── SHOULD PASS: real backgrounds, small elements, neutrals, rich art ───────── */
|
||||
|
||||
/* Opaque radial background — a real surface, not a glow (no transparent end). */
|
||||
.pass-opaque-bg {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, #1e3a8a, #0f172a);
|
||||
}
|
||||
/* Small badge: chromatic low-alpha radial glow, but tiny. */
|
||||
.pass-badge {
|
||||
width: 40px; height: 40px;
|
||||
background: radial-gradient(circle, rgba(80,111,255,0.30), transparent);
|
||||
}
|
||||
/* Avatar-scale light: the subject IS a small light, exempt by size. */
|
||||
.pass-avatar-light {
|
||||
width: 72px; height: 72px;
|
||||
background: radial-gradient(circle, rgba(255,196,64,0.35), transparent 70%);
|
||||
}
|
||||
/* Neutral near-black vignette — grayscale, not the accent-glow tell. */
|
||||
.pass-neutral-vignette {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, rgba(0,0,0,0.40), transparent 80%);
|
||||
}
|
||||
/* Neutral near-white vignette — grayscale, exempt. */
|
||||
.pass-white-vignette {
|
||||
width: 820px; height: 540px;
|
||||
background: radial-gradient(ellipse, rgba(255,255,255,0.35), transparent 70%);
|
||||
}
|
||||
/* Rich three-color radial composition — real color structure, opaque, no fade. */
|
||||
.pass-rich-composition {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, #ff0080 0%, #7928ca 45%, #0070f3 100%);
|
||||
}
|
||||
/* Rich composition that does fade out, but its color stops are opaque. */
|
||||
.pass-rich-transparent {
|
||||
width: 780px; height: 500px;
|
||||
background: radial-gradient(circle, rgba(255,0,128,0.90) 0%, rgba(121,40,202,0.85) 50%, transparent);
|
||||
}
|
||||
/* Opaque center glow (the radial-halo case, alpha ≥ 0.45) — not this rule. */
|
||||
.pass-opaque-center {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, rgba(80,111,255,0.85), transparent);
|
||||
}
|
||||
/* Low-alpha chromatic wash, but LINEAR, not radial. */
|
||||
.pass-linear-wash {
|
||||
width: 800px; height: 520px;
|
||||
background: linear-gradient(120deg, rgba(80,111,255,0.28), transparent 60%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="grid">
|
||||
<!-- should-flag column -->
|
||||
<div class="case flag-hero-blue" data-name="Hero Spotlight Blue"></div>
|
||||
<div class="case flag-section-violet" data-name="Section Glow Violet"></div>
|
||||
<div class="case flag-overlay-cyan" data-name="Overlay Glow Cyan"></div>
|
||||
<div class="case flag-two-stop-rose" data-name="Two Stop Soft Glow"></div>
|
||||
<div class="case flag-hex-alpha" data-name="Hex Alpha Glow"></div>
|
||||
|
||||
<!-- should-pass column -->
|
||||
<div class="case pass-opaque-bg" data-name="Opaque Radial Background"></div>
|
||||
<div class="case pass-badge" data-name="Small Accent Badge"></div>
|
||||
<div class="case pass-avatar-light" data-name="Avatar Glow Light"></div>
|
||||
<div class="case pass-neutral-vignette" data-name="Neutral Vignette"></div>
|
||||
<div class="case pass-white-vignette" data-name="White Vignette"></div>
|
||||
<div class="case pass-rich-composition" data-name="Rich Radial Composition"></div>
|
||||
<div class="case pass-rich-transparent" data-name="Rich Transparent Composition"></div>
|
||||
<div class="case pass-opaque-center" data-name="Opaque Center Glow"></div>
|
||||
<div class="case pass-linear-wash" data-name="Linear Gradient Wash"></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user