detector: hero pulsing-dot promotion, nav-CTA contrast gap closure, shape-assembled-illustration (56 -> 57)

Item 1 (hero liveness theater):
- pulsing-dot now merges declarations per selector across rule blocks
  (cascade-approximate), descends into media queries, and strips
  prefers-reduced-motion: reduce overrides before the predicate runs.
  Catches the shipped split-block constructions (size in the base rule,
  animation added later or inside a no-preference media block).
- Dots whose element sits inside a header/nav landmark are promoted to
  error severity (string-level landmark ranges in both engines); the
  browser engine additionally promotes dots resting in the first ~900px.
- blinking-cursor findings in the first ~900px or inside header/nav are
  promoted from advisory to warning.
- Per-finding severity overrides now flow through static-html,
  browser-injected serialization, and detect-url.

Item 2 (nav-CTA contrast constructions):
- The a24-opus 01/002 header CTA already fires (specificity cascade +
  oklch + var() all resolved); systematic sweep found two remaining
  escapes and closes both:
  - own gradient background on a SAFE_TAGS element (checkColors styled-
    control exception now treats an own gradient as an own surface,
    contrast measured against the worst stop)
  - ::before/::after full-cover surface (static cascade marks pseudo
    surfaces; browser adapter reads the pseudo computed style) so text is
    measured against the surface the browser actually paints
- nav-cta-constructions fixture locks all eight computable construction
  families; background-image: url() remains unflaggable by design.

Item 3 (shape-assembled-illustration, slop/advisory):
- New rule for large inline SVGs composing a pictorial scene from >= 8
  primitive shapes at >= 200x200 intrinsic size with >= 3 distinct fills.
  Charts (axis labels), stroke-only technical drawings, icons/logos
  (small explicit size), and pattern-tiled backgrounds are exempt.
  1.8 percent fire rate over the 3069-sample eval corpus, all verified
  pictorial scenes; zero fires across val-a22/val-a24.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-14 19:29:29 -07:00
co-authored by Claude Fable 5
parent 1734f13a2e
commit dc0b25d393
14 changed files with 928 additions and 36 deletions
+21 -3
View File
@@ -1222,7 +1222,7 @@ if (IS_BROWSER) {
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
severity: f.severity || ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1489,7 +1489,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 })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
@@ -1588,7 +1588,25 @@ if (IS_BROWSER) {
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
const mapped = htmlPatternFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
} else if (f.id === 'pulsing-dot' && f.selector) {
// The string scan promotes header/nav dots on its own; with a live
// layout also promote dots resting in the first ~900px of the page
// (the hero region), which the source scan cannot measure.
try {
const dotEl = document.querySelector(f.selector);
if (dotEl) {
const rect = dotEl.getBoundingClientRect();
const pageTop = rect.top + (window.scrollY || 0);
if (pageTop <= 900) item.severity = 'error';
}
} catch { /* unresolvable selector: keep registry severity */ }
}
return item;
}).filter(f => _ruleOk(f.type));
pageLevelFindings.push(...mapped);
addBrowserFindings(groupMap, document.body, mapped);
}
+272 -13
View File
@@ -224,6 +224,15 @@ const ANTIPATTERNS = [
'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
skillSection: 'Motion',
},
{
id: 'shape-assembled-illustration',
category: 'slop',
severity: 'advisory',
name: 'Shape-assembled illustration',
description:
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'dark-glow',
category: 'slop',
@@ -853,7 +862,13 @@ function checkColors(opts) {
// 1.2:1; the old a/button-only exception never looked at it.) The 9px
// font floor keeps sub-text decorations out.
const isStyledControl = hasDirectText
&& bgColor && bgColor.a > 0.5
&& ((bgColor && bgColor.a > 0.5)
// A gradient painted on the element itself is an own surface the
// same way a solid background is. Without this branch a nav CTA
// built as `<a>` with `background: linear-gradient(…)` and a text
// color that fails against every stop sails through on the
// SAFE_TAGS suppression (the shipped escape).
|| (bgImage && /gradient/i.test(bgImage)))
&& fontSize >= 9;
if (!isStyledControl) return [];
}
@@ -1805,22 +1820,129 @@ function isRoundDotRadius(radiusValue, w, h) {
return px >= 999 || px >= 0.4 * Math.min(w, h);
}
// Remove @media blocks whose condition is prefers-reduced-motion: reduce.
// Those blocks describe the accessibility fallback, not the default
// experience that ships — an `animation: none` reset inside one must not
// mask the resting-state animation the page plays for everyone else.
function stripReducedMotionBlocks(content) {
const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi;
let out = '';
let last = 0;
let m;
while ((m = re.exec(content)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < content.length && depth > 0) {
const ch = content.charCodeAt(i);
if (ch === 0x7b /* { */) depth++;
else if (ch === 0x7d /* } */) depth--;
i++;
}
out += content.slice(last, m.index);
last = i;
re.lastIndex = i;
}
return out + content.slice(last);
}
// Source-index ranges of <header> and <nav> landmark elements in an HTML
// string. Lets string-level scans decide whether a matched element sits in
// the page chrome (the hero/nav region) without needing a DOM.
function landmarkSourceRanges(content) {
const ranges = [];
for (const tag of ['header', 'nav']) {
const re = new RegExp(`<${tag}\\b|</${tag}\\s*>`, 'gi');
const stack = [];
let m;
while ((m = re.exec(content)) !== null) {
if (m[0].charAt(1) === '/') {
const start = stack.pop();
if (start != null) ranges.push([start, m.index]);
} else {
stack.push(m.index);
}
}
}
return ranges;
}
function indexInSourceRanges(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
// Does any element targeted by the final compound of `selector` appear
// inside a header/nav landmark range of the HTML source? Resolves the last
// .class or #id token of the selector against class/id attributes; a
// tag-only compound is never resolvable this way and returns false
// (conservative: no promotion without placement evidence).
function selectorHitsLandmark(content, selector, ranges) {
if (!ranges || ranges.length === 0) return false;
const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || '';
const idMatch = last.match(/#([A-Za-z_][\w-]*)/);
const classMatch = last.match(/\.([A-Za-z_][\w-]*)/);
let attrRe = null;
if (idMatch) {
const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi');
} else if (classMatch) {
const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["']`, 'gi');
}
if (!attrRe) return false;
let m;
while ((m = attrRe.exec(content)) !== null) {
if (indexInSourceRanges(m.index, ranges)) return true;
}
return false;
}
// Small circular indicator bound to an infinite pulse animation — the
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
// (border-radius >= 40% or pill values), and an infinite animation whose
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
// the keyframes aren't in the scanned text). Rotation-only animations
// (spinners) never flag.
//
// Declarations for one selector are merged across rule blocks before the
// predicate runs: size in the base rule plus the animation added in a
// second block (or inside a matching @media block) is the construction
// that ships. prefers-reduced-motion: reduce overrides are stripped first
// so their animation resets don't mask the default experience. A dot whose
// element sits inside a header/nav landmark is the hero liveness cliché
// and is promoted to error severity; occurrences elsewhere keep the
// registry default severity.
function scanCssTextForPulsingDot(content) {
const customProps = collectCssCustomProps(content);
const keyframes = collectPulseKeyframes(content);
const heroRanges = landmarkSourceRanges(content);
const findings = [];
const seen = new Set();
// Merge declarations per selector across rule blocks, approximating the
// cascade: later declarations for the same property win. Comma lists are
// split so `.a, .b { … }` contributes to both selectors. Comments are
// stripped first so they neither pollute selector keys nor smuggle a
// comma into the selector-list split.
const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' ');
const merged = new Map();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
while ((m = ruleRe.exec(scanText)) !== null) {
const decls = parseCssDeclBlock(m[2]);
if (decls.size === 0) continue;
for (const rawSelector of m[1].split(',')) {
const selector = rawSelector.trim();
if (!selector || selector.startsWith('@')) continue;
let acc = merged.get(selector);
if (!acc) {
acc = new Map();
merged.set(selector, acc);
}
for (const [prop, value] of decls) acc.set(prop, value);
}
}
for (const [selector, decls] of merged) {
const names = infiniteAnimationNames(decls);
if (names.length === 0) continue;
const pulseName = names.find(n => {
@@ -1841,9 +1963,12 @@ function scanCssTextForPulsingDot(content) {
if (seen.has(selector)) continue;
seen.add(selector);
const inLandmark = selectorHitsLandmark(content, selector, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation`,
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
selector,
...(inLandmark ? { severity: 'error' } : {}),
});
}
@@ -1860,15 +1985,78 @@ function scanCssTextForPulsingDot(content) {
const key = `tw:${cls}`;
if (seen.has(key)) continue;
seen.add(key);
const inLandmark = indexInSourceRanges(cm.index, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `animate-${anim[1]} on tiny rounded-full element`,
snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`,
...(inLandmark ? { severity: 'error' } : {}),
});
}
return findings;
}
// Shape-assembled illustration: a large inline SVG composing a pictorial
// scene from many primitive shapes (rect / circle / ellipse / polygon) in
// several fill colors — the clip-art hero mascot. Gates keep the legitimate
// SVG population out:
// • icons and logos: intrinsic size gate (>= 200px on both axes, from
// width/height attributes or the viewBox when no explicit size is set)
// • charts / labeled diagrams: more than two <text>/<tspan> nodes exempts
// the graphic (axis labels, callouts)
// • line drawings / technical diagrams: primitive count < 8 or fewer
// than 3 distinct fills never qualifies (stroke-only art has no fills)
// • tiling background textures: any <pattern> definition exempts
function scanHtmlForShapeAssembledIllustration(html) {
const findings = [];
const svgRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
let m;
while ((m = svgRe.exec(html)) !== null) {
const block = m[0];
const openTag = (block.match(/^<svg\b[^>]*>/i) || [''])[0];
// Data-bearing or annotated graphics: axis labels and callout text
// mark a chart or diagram, not a mascot.
const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length;
if (textCount > 2) continue;
// Tiling texture definitions are decorative backgrounds, not scenes.
if (/<pattern\b/i.test(block)) continue;
const primitives = (block.match(/<(?:rect|circle|ellipse|polygon)\b/gi) || []).length;
if (primitives < 8) continue;
// Intrinsic size: explicit width/height attributes win; fall back to
// the viewBox box. Percentage or missing sizes stay unresolvable on
// that axis and the viewBox speaks for them.
const attrDim = (name) => {
// (?<![-\w]) keeps compound attributes like stroke-width from
// masquerading as the svg's own width.
const am = openTag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']\\s*([\\d.]+)(?:px)?\\s*["']`, 'i'));
return am ? parseFloat(am[1]) : null;
};
const vb = openTag.match(/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i);
const w = attrDim('width') ?? (vb ? parseFloat(vb[1]) : null);
const h = attrDim('height') ?? (vb ? parseFloat(vb[2]) : null);
if (w == null || h == null || w < 200 || h < 200) continue;
// Distinct fill paints (attributes and inline styles), excluding
// non-paints. Multiple fills are what turn a shape pile into a scene.
const fills = new Set();
for (const fm of block.matchAll(/\bfill\s*[:=]\s*["']?\s*([^"';>}\s]+)/gi)) {
const paint = fm[1].trim().toLowerCase();
if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue;
fills.add(paint);
}
if (fills.size < 3) continue;
findings.push({
id: 'shape-assembled-illustration',
snippet: `inline <svg> scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
});
}
return findings;
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
@@ -1990,6 +2178,9 @@ function checkHtmlPatterns(html) {
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(html));
@@ -2382,6 +2573,28 @@ function checkElementPseudoStripeDOM(el) {
return findings;
}
// Full-cover surface pseudo (browser): a ::before/::after positioned
// absolute/fixed whose box covers (nearly) the whole host and carries an
// opaque background. That pseudo is the element's visible surface even
// though the element's own background-color reads transparent — the nav-CTA
// construction that otherwise escapes every own-background contrast gate.
function readPseudoSurfaceDOM(el, rect) {
for (const which of ['::before', '::after']) {
let ps;
try { ps = getComputedStyle(el, which); } catch { continue; }
if (!ps || ps.content === 'none' || ps.content === '') continue;
if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
if (w < rect.width - 4 || h < rect.height - 4) continue;
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
if (!bg || (bg.a ?? 1) < 0.9) continue;
return bg;
}
return null;
}
function checkElementColorsDOM(el) {
const tag = el.tagName.toLowerCase();
// No early SAFE_TAGS bail here — checkColors() does its own gating that
@@ -2392,7 +2605,15 @@ function checkElementColorsDOM(el) {
const style = getComputedStyle(el);
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el);
let effectiveBg = resolveBackground(el);
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
// Chrome serializes computed colors specified in modern spaces as
@@ -2401,7 +2622,7 @@ function checkElementColorsDOM(el) {
// silently never run (the shipped miss: a nav CTA whose text color was
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: readOwnBackgroundColor(el, style),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
@@ -3773,15 +3994,28 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// map first (mirrors the textColor path above). Without this a chip whose
// background is `var(--sev)` reads as no-own-bg in the static engine and
// the styled-control contrast exception never engages.
const ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
let ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
|| readOwnBackgroundColor(el, style);
// Full-cover surface pseudo (static): the cascade pass marks elements
// whose ::before/::after paints an opaque covering surface. When the
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
textColor,
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5037,9 +5271,16 @@ function checkElementBlinkingCursorDOM(el) {
}
if (!glyphCursor && !blockCursor) return [];
// Hero-region promotion: a fake caret blinking in the first ~900px or
// inside the page chrome is the shipped hero cliché, not an incidental
// flourish. Promote those from the registry's advisory to warning;
// lower first-viewport occurrences keep the default severity.
const inHeroRegion = pageTop <= 900
|| !!(el.closest && el.closest('header, nav, [role="banner"], [role="navigation"]'));
return [{
id: 'blinking-cursor',
snippet: `${classSelector(el)}${Math.round(rect.width)}x${Math.round(rect.height)}px blinking cursor (animation "${blinkName}") in the first viewport`,
...(inHeroRegion ? { severity: 'warning' } : {}),
}];
}
@@ -6423,7 +6664,7 @@ if (IS_BROWSER) {
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
severity: f.severity || ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -6690,7 +6931,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 })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
@@ -6789,7 +7030,25 @@ if (IS_BROWSER) {
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
const mapped = htmlPatternFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
} else if (f.id === 'pulsing-dot' && f.selector) {
// The string scan promotes header/nav dots on its own; with a live
// layout also promote dots resting in the first ~900px of the page
// (the hero region), which the source scan cannot measure.
try {
const dotEl = document.querySelector(f.selector);
if (dotEl) {
const rect = dotEl.getBoundingClientRect();
const pageTop = rect.top + (window.scrollY || 0);
if (pageTop <= 900) item.severity = 'error';
}
} catch { /* unresolvable selector: keep registry severity */ }
}
return item;
}).filter(f => _ruleOk(f.type));
pageLevelFindings.push(...mapped);
addBrowserFindings(groupMap, document.body, mapped);
}
+4 -1
View File
@@ -254,7 +254,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, ignoreValue: f.ignoreValue || '' }))
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
);
});
// Content invisible at rest: reveal sweep, then re-measure. Runs after
@@ -298,6 +298,9 @@ async function detectUrl(url, options = {}) {
return filterByProviders(results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return item;
}), options.providers);
}
@@ -850,6 +850,11 @@ class StaticDocument {
this._styleMap = new WeakMap();
this._hoverStyleMap = new WeakMap();
this._accentDashPseudo = new WeakSet();
// Elements whose ::before/::after paints a full-cover opaque surface
// (position absolute/fixed + inset 0 + solid background). The pseudo is
// the element's visible background for contrast purposes even though it
// never joins the element cascade.
this._pseudoSurface = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
@@ -898,6 +903,12 @@ class StaticDocument {
hasAccentDashPseudo(el) {
return this._accentDashPseudo.has(el.node);
}
setPseudoSurface(node, color) {
this._pseudoSurface.set(node, color);
}
getPseudoSurface(el) {
return this._pseudoSurface.get(el.node) || null;
}
}
function makeStaticStyle(values = {}) {
@@ -915,6 +926,7 @@ function buildStaticWindow(staticDoc) {
getComputedStyle: (el) => staticDoc.getStyle(el),
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
getPseudoSurface: (el) => staticDoc.getPseudoSurface(el),
};
}
@@ -991,6 +1003,33 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
} catch { /* unsupported base selector */ }
}
}
// Full-cover surface pseudo: the CTA construction where the
// element itself stays transparent and a ::before/::after with
// position absolute/fixed + inset 0 (or all four sides 0, or
// 100% width and height) plus an opaque background paints the
// visible surface. Mark base-selector matches so the contrast
// checks measure text against the surface the browser renders.
const pseudoPos = String(decls.get('position') || '').toLowerCase();
if (pseudoPos === 'absolute' || pseudoPos === 'fixed') {
const zeroLen = v => v != null && /^0(?:px)?$/.test(String(v).trim());
const insetRaw = String(decls.get('inset') || '').trim();
const coversBox = (insetRaw !== '' && insetRaw.split(/\s+/).every(t => /^0(?:px)?$/.test(t)))
|| ['top', 'right', 'bottom', 'left'].every(side => zeroLen(decls.get(side)))
|| (String(decls.get('width') || '').trim() === '100%'
&& String(decls.get('height') || '').trim() === '100%');
if (coversBox && decls.has('content')) {
const surfRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
const surfToken = surfRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
const surf = parseAnyColor(surfToken ? surfToken[0] : surfRaw);
if (surf && (surf.a ?? 1) >= 0.9 && !/gradient/i.test(surfRaw)) {
try {
for (const node of modules.selectAll(pm[1], root.children || [])) {
staticDoc.setPseudoSurface(node, surf);
}
} catch { /* unsupported base selector */ }
}
}
}
continue;
}
}
@@ -222,7 +222,12 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
const item = finding(f.id, filePath, f.snippet);
// Position-aware severity promotion: checks may attach a per-finding
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
+9
View File
@@ -122,6 +122,15 @@ const ANTIPATTERNS = [
'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
skillSection: 'Motion',
},
{
id: 'shape-assembled-illustration',
category: 'slop',
severity: 'advisory',
name: 'Shape-assembled illustration',
description:
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'dark-glow',
category: 'slop',
+243 -10
View File
@@ -91,7 +91,13 @@ function checkColors(opts) {
// 1.2:1; the old a/button-only exception never looked at it.) The 9px
// font floor keeps sub-text decorations out.
const isStyledControl = hasDirectText
&& bgColor && bgColor.a > 0.5
&& ((bgColor && bgColor.a > 0.5)
// A gradient painted on the element itself is an own surface the
// same way a solid background is. Without this branch a nav CTA
// built as `<a>` with `background: linear-gradient(…)` and a text
// color that fails against every stop sails through on the
// SAFE_TAGS suppression (the shipped escape).
|| (bgImage && /gradient/i.test(bgImage)))
&& fontSize >= 9;
if (!isStyledControl) return [];
}
@@ -1043,22 +1049,129 @@ function isRoundDotRadius(radiusValue, w, h) {
return px >= 999 || px >= 0.4 * Math.min(w, h);
}
// Remove @media blocks whose condition is prefers-reduced-motion: reduce.
// Those blocks describe the accessibility fallback, not the default
// experience that ships — an `animation: none` reset inside one must not
// mask the resting-state animation the page plays for everyone else.
function stripReducedMotionBlocks(content) {
const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi;
let out = '';
let last = 0;
let m;
while ((m = re.exec(content)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < content.length && depth > 0) {
const ch = content.charCodeAt(i);
if (ch === 0x7b /* { */) depth++;
else if (ch === 0x7d /* } */) depth--;
i++;
}
out += content.slice(last, m.index);
last = i;
re.lastIndex = i;
}
return out + content.slice(last);
}
// Source-index ranges of <header> and <nav> landmark elements in an HTML
// string. Lets string-level scans decide whether a matched element sits in
// the page chrome (the hero/nav region) without needing a DOM.
function landmarkSourceRanges(content) {
const ranges = [];
for (const tag of ['header', 'nav']) {
const re = new RegExp(`<${tag}\\b|</${tag}\\s*>`, 'gi');
const stack = [];
let m;
while ((m = re.exec(content)) !== null) {
if (m[0].charAt(1) === '/') {
const start = stack.pop();
if (start != null) ranges.push([start, m.index]);
} else {
stack.push(m.index);
}
}
}
return ranges;
}
function indexInSourceRanges(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
// Does any element targeted by the final compound of `selector` appear
// inside a header/nav landmark range of the HTML source? Resolves the last
// .class or #id token of the selector against class/id attributes; a
// tag-only compound is never resolvable this way and returns false
// (conservative: no promotion without placement evidence).
function selectorHitsLandmark(content, selector, ranges) {
if (!ranges || ranges.length === 0) return false;
const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || '';
const idMatch = last.match(/#([A-Za-z_][\w-]*)/);
const classMatch = last.match(/\.([A-Za-z_][\w-]*)/);
let attrRe = null;
if (idMatch) {
const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi');
} else if (classMatch) {
const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["']`, 'gi');
}
if (!attrRe) return false;
let m;
while ((m = attrRe.exec(content)) !== null) {
if (indexInSourceRanges(m.index, ranges)) return true;
}
return false;
}
// Small circular indicator bound to an infinite pulse animation — the
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
// (border-radius >= 40% or pill values), and an infinite animation whose
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
// the keyframes aren't in the scanned text). Rotation-only animations
// (spinners) never flag.
//
// Declarations for one selector are merged across rule blocks before the
// predicate runs: size in the base rule plus the animation added in a
// second block (or inside a matching @media block) is the construction
// that ships. prefers-reduced-motion: reduce overrides are stripped first
// so their animation resets don't mask the default experience. A dot whose
// element sits inside a header/nav landmark is the hero liveness cliché
// and is promoted to error severity; occurrences elsewhere keep the
// registry default severity.
function scanCssTextForPulsingDot(content) {
const customProps = collectCssCustomProps(content);
const keyframes = collectPulseKeyframes(content);
const heroRanges = landmarkSourceRanges(content);
const findings = [];
const seen = new Set();
// Merge declarations per selector across rule blocks, approximating the
// cascade: later declarations for the same property win. Comma lists are
// split so `.a, .b { … }` contributes to both selectors. Comments are
// stripped first so they neither pollute selector keys nor smuggle a
// comma into the selector-list split.
const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' ');
const merged = new Map();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
while ((m = ruleRe.exec(scanText)) !== null) {
const decls = parseCssDeclBlock(m[2]);
if (decls.size === 0) continue;
for (const rawSelector of m[1].split(',')) {
const selector = rawSelector.trim();
if (!selector || selector.startsWith('@')) continue;
let acc = merged.get(selector);
if (!acc) {
acc = new Map();
merged.set(selector, acc);
}
for (const [prop, value] of decls) acc.set(prop, value);
}
}
for (const [selector, decls] of merged) {
const names = infiniteAnimationNames(decls);
if (names.length === 0) continue;
const pulseName = names.find(n => {
@@ -1079,9 +1192,12 @@ function scanCssTextForPulsingDot(content) {
if (seen.has(selector)) continue;
seen.add(selector);
const inLandmark = selectorHitsLandmark(content, selector, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation`,
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
selector,
...(inLandmark ? { severity: 'error' } : {}),
});
}
@@ -1098,15 +1214,78 @@ function scanCssTextForPulsingDot(content) {
const key = `tw:${cls}`;
if (seen.has(key)) continue;
seen.add(key);
const inLandmark = indexInSourceRanges(cm.index, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `animate-${anim[1]} on tiny rounded-full element`,
snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`,
...(inLandmark ? { severity: 'error' } : {}),
});
}
return findings;
}
// Shape-assembled illustration: a large inline SVG composing a pictorial
// scene from many primitive shapes (rect / circle / ellipse / polygon) in
// several fill colors — the clip-art hero mascot. Gates keep the legitimate
// SVG population out:
// • icons and logos: intrinsic size gate (>= 200px on both axes, from
// width/height attributes or the viewBox when no explicit size is set)
// • charts / labeled diagrams: more than two <text>/<tspan> nodes exempts
// the graphic (axis labels, callouts)
// • line drawings / technical diagrams: primitive count < 8 or fewer
// than 3 distinct fills never qualifies (stroke-only art has no fills)
// • tiling background textures: any <pattern> definition exempts
function scanHtmlForShapeAssembledIllustration(html) {
const findings = [];
const svgRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
let m;
while ((m = svgRe.exec(html)) !== null) {
const block = m[0];
const openTag = (block.match(/^<svg\b[^>]*>/i) || [''])[0];
// Data-bearing or annotated graphics: axis labels and callout text
// mark a chart or diagram, not a mascot.
const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length;
if (textCount > 2) continue;
// Tiling texture definitions are decorative backgrounds, not scenes.
if (/<pattern\b/i.test(block)) continue;
const primitives = (block.match(/<(?:rect|circle|ellipse|polygon)\b/gi) || []).length;
if (primitives < 8) continue;
// Intrinsic size: explicit width/height attributes win; fall back to
// the viewBox box. Percentage or missing sizes stay unresolvable on
// that axis and the viewBox speaks for them.
const attrDim = (name) => {
// (?<![-\w]) keeps compound attributes like stroke-width from
// masquerading as the svg's own width.
const am = openTag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']\\s*([\\d.]+)(?:px)?\\s*["']`, 'i'));
return am ? parseFloat(am[1]) : null;
};
const vb = openTag.match(/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i);
const w = attrDim('width') ?? (vb ? parseFloat(vb[1]) : null);
const h = attrDim('height') ?? (vb ? parseFloat(vb[2]) : null);
if (w == null || h == null || w < 200 || h < 200) continue;
// Distinct fill paints (attributes and inline styles), excluding
// non-paints. Multiple fills are what turn a shape pile into a scene.
const fills = new Set();
for (const fm of block.matchAll(/\bfill\s*[:=]\s*["']?\s*([^"';>}\s]+)/gi)) {
const paint = fm[1].trim().toLowerCase();
if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue;
fills.add(paint);
}
if (fills.size < 3) continue;
findings.push({
id: 'shape-assembled-illustration',
snippet: `inline <svg> scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
});
}
return findings;
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
@@ -1228,6 +1407,9 @@ function checkHtmlPatterns(html) {
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(html));
@@ -1620,6 +1802,28 @@ function checkElementPseudoStripeDOM(el) {
return findings;
}
// Full-cover surface pseudo (browser): a ::before/::after positioned
// absolute/fixed whose box covers (nearly) the whole host and carries an
// opaque background. That pseudo is the element's visible surface even
// though the element's own background-color reads transparent — the nav-CTA
// construction that otherwise escapes every own-background contrast gate.
function readPseudoSurfaceDOM(el, rect) {
for (const which of ['::before', '::after']) {
let ps;
try { ps = getComputedStyle(el, which); } catch { continue; }
if (!ps || ps.content === 'none' || ps.content === '') continue;
if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
if (w < rect.width - 4 || h < rect.height - 4) continue;
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
if (!bg || (bg.a ?? 1) < 0.9) continue;
return bg;
}
return null;
}
function checkElementColorsDOM(el) {
const tag = el.tagName.toLowerCase();
// No early SAFE_TAGS bail here — checkColors() does its own gating that
@@ -1630,7 +1834,15 @@ function checkElementColorsDOM(el) {
const style = getComputedStyle(el);
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el);
let effectiveBg = resolveBackground(el);
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
// Chrome serializes computed colors specified in modern spaces as
@@ -1639,7 +1851,7 @@ function checkElementColorsDOM(el) {
// silently never run (the shipped miss: a nav CTA whose text color was
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: readOwnBackgroundColor(el, style),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
@@ -3011,15 +3223,28 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// map first (mirrors the textColor path above). Without this a chip whose
// background is `var(--sev)` reads as no-own-bg in the static engine and
// the styled-control contrast exception never engages.
const ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
let ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
|| readOwnBackgroundColor(el, style);
// Full-cover surface pseudo (static): the cascade pass marks elements
// whose ::before/::after paints an opaque covering surface. When the
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
textColor,
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -4275,9 +4500,16 @@ function checkElementBlinkingCursorDOM(el) {
}
if (!glyphCursor && !blockCursor) return [];
// Hero-region promotion: a fake caret blinking in the first ~900px or
// inside the page chrome is the shipped hero cliché, not an incidental
// flourish. Promote those from the registry's advisory to warning;
// lower first-viewport occurrences keep the default severity.
const inHeroRegion = pageTop <= 900
|| !!(el.closest && el.closest('header, nav, [role="banner"], [role="navigation"]'));
return [{
id: 'blinking-cursor',
snippet: `${classSelector(el)}${Math.round(rect.width)}x${Math.round(rect.height)}px blinking cursor (animation "${blinkName}") in the first viewport`,
...(inHeroRegion ? { severity: 'warning' } : {}),
}];
}
@@ -4462,6 +4694,7 @@ export {
collectCssCustomProps,
cssLengthToPx,
scanCssTextForPulsingDot,
scanHtmlForShapeAssembledIllustration,
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,