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:
Paul Bakaus
2026-07-24 17:19:04 -07:00
co-authored by Claude Fable 5
parent e3f732e99c
commit 6ff9f957ac
9 changed files with 433 additions and 5 deletions
+1
View File
@@ -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 })),
+135
View File
@@ -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 = {}) {
+9
View File
@@ -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',
+128
View File
@@ -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,