detector: grid-background variants, dash-prefix eyebrow, marquee rule, inset-shadow stripes

Four gaps from human review of gpt-5.6 eval artifacts:

1. codex-grid-background variants: the block scan now also matches the
   inverted end-of-tile hairline form (transparent calc(100% - Npx))
   and reads the tile cell from the background shorthand's `/ Npx Npx`
   slot, not just background-size declarations. A single hairline layer
   qualifies when tiled by a px pair cell (page-scale line field);
   percent-tiled single hairlines (background-size: 25% 100% rules on
   data-viz tracks/graphs) stay legal.

2. hero-eyebrow-chip branch C (dash-prefix): sentence-case, regular-
   weight microlabels above the h1 announced by a short chromatic
   ::before/::after bar (8-80px x 1-6px, accent fill). Static cascade
   marks dash-pseudo targets during rule collection; the browser path
   reads getComputedStyle(el, '::before'/'::after').

3. New `marquee` slop rule: <marquee> elements, and infinite animations
   bound to keyframes with >= 20 percentage points of X travel. Percent
   travel only — px-travel loops are bespoke product animations
   (waveform playheads, progress sweeps). Centered elements animating
   other properties (constant -50% X), non-infinite slide-ins, rotations,
   and pulses never qualify.

4. side-tab inset box-shadow variant: single-edge inset shadows
   (3-12px offset on one axis, no blur/spread, chromatic) drawn as
   stripes on cards/badges/menu items. Selection-state indicators
   ([aria-current], [aria-selected], [role=tab], active/current/selected
   hints, interaction states) stay exempt; the same stripe repeated
   unconditionally on every item flags. Narrow fixed-width glyphs
   (logo marks) are exempt. isTabContextElement narrowed to match:
   bare nav ancestry no longer blanket-exempts top/bottom border
   stripes — only explicit tab semantics or state markers do.

Browser bundle regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-12 18:38:59 -07:00
co-authored by Claude Fable 5
parent 5635b1d484
commit ea44f514f9
6 changed files with 657 additions and 30 deletions
+214 -14
View File
@@ -233,6 +233,15 @@ const ANTIPATTERNS = [
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'marquee',
category: 'slop',
name: 'Auto-scrolling marquee',
description:
'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.',
skillSection: 'Motion',
skillGuideline: 'auto-scrolling marquee',
},
{
id: 'icon-tile-stack',
category: 'slop',
@@ -538,7 +547,7 @@ const ANTIPATTERNS = [
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
@@ -1039,6 +1048,7 @@ function checkHeroEyebrow(opts) {
siblingTag, siblingText, siblingTextTransform,
siblingFontSize, siblingLetterSpacing,
siblingFontWeight, siblingColor,
siblingHasAccentDashPseudo,
} = opts;
if (headingTag !== 'h1') return [];
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
@@ -1070,11 +1080,16 @@ function checkHeroEyebrow(opts) {
const weight = Number(siblingFontWeight) || 400;
const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
if (!isClassicTracked && !isAccentBold) return [];
// Branch C: dash-prefix eyebrow — sentence case, low tracking, regular
// weight, but announced by a short chromatic ::before/::after bar
// (the kicker dash). Same label-above-headline pattern, third styling.
const isDashPrefixed = !!siblingHasAccentDashPseudo;
if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return [];
const headingTextSnippet = (headingText || '').trim().slice(0, 60);
const eyebrowSnippet = text.slice(0, 40);
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix';
return [{
id: 'hero-eyebrow-chip',
snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
@@ -1509,6 +1524,137 @@ function scanCssTextForPseudoStripe(content) {
return findings;
}
// Side-tab stripe drawn as a single-edge inset box-shadow
// (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color):
// paints a bar along one edge with no border property involved, so the
// element-level border checks never see it. Selection-state indicators
// are exempt — an inset stripe on [aria-current] / .active / [role=tab]
// marks the selected item; the same stripe unconditionally on every item
// is decoration and flags.
function scanCssTextForInsetStripe(content) {
const customProps = collectCssCustomProps(content);
const findings = [];
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
// State/selection contexts: current-item markers, interaction states,
// explicit tab semantics.
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
if (/\[aria-(?:current|selected)/i.test(selector)) continue;
if (/\[role=["']?tab/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected|tabs?)(?![\w])/i.test(selector)) continue;
// Structural tags where a single-edge inset shadow is depth/quoting,
// not an accent stripe.
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
const decls = parseCssDeclBlock(m[2]);
const shadow = decls.get('box-shadow');
if (!shadow || !/\binset\b/i.test(shadow)) continue;
// Narrow fixed-width elements (logo marks, icon glyphs) use inset
// fills as artwork, not edge stripes. Stripe targets — cards, badges,
// menu items — are wider or leave width to layout.
const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps));
if (declaredWidth != null && declaredWidth <= 40) continue;
const value = resolveVarRefs(shadow, customProps);
for (const layer of value.split(/,(?![^(]*\))/)) {
if (!/\binset\b/i.test(layer)) continue;
const colorInfo = findShadowColor(layer);
// Unresolvable colors (currentColor, external vars): don't guess.
if (!colorInfo || !colorInfo.color) continue;
const c = colorInfo.color;
if ((c.a ?? 1) < 0.1) continue;
const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
if (chroma < 30) continue;
const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0;
if (blur !== 0 || sp !== 0) continue;
const ax = Math.abs(x), ay = Math.abs(y);
const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0);
if (!isStripe) continue;
if (seen.has(selector)) break;
seen.add(selector);
const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
});
break;
}
}
return findings;
}
// Collect @keyframes names whose body travels horizontally — the marquee
// loop. X travel is measured across every translateX/translate/translate3d
// X component in the body: a centered element animating something else
// keeps a constant -50% X (zero travel) and never qualifies, while a
// ticker moves from its resting position to a large offset. Keyframes
// with a single X sample that also vary scale/opacity read as pulses or
// breathes, not marquees.
function collectMarqueeKeyframes(content) {
const names = new Set();
const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
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++;
}
const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
re.lastIndex = i;
// Only percentage travel qualifies: a content marquee translates by a
// fraction of its own (unknown) track width, so generated tickers use
// -50% / -100%. Pixel-travel loops are bespoke product animations —
// sweeping playheads, progress indicators — not marquees.
const pct = [];
const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi;
let xm;
while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1]));
if (pct.length === 0) continue;
if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue;
// Implicit start: a lone declared X animates from the element's
// resting position, so its magnitude is the travel.
const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]);
if (travelPct >= 20) names.add(m[1]);
}
return names;
}
// Auto-scrolling marquee: a <marquee> element, or an infinite animation
// bound to a keyframe loop that travels a large horizontal distance.
// Rotation/opacity animations never qualify (no X travel); JS-driven
// carousels with user controls have no infinite CSS X-loop to match.
function scanCssTextForMarquee(content) {
const findings = [];
if (/<marquee\b/i.test(content)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
const decls = parseCssDeclBlock(m[2]);
for (const name of infiniteAnimationNames(decls)) {
if (!marqueeKeyframes.has(name)) continue;
const key = `${selector}${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
}
}
return findings;
}
// Collect @keyframes names and whether each one reads as a "pulse" —
// i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
// (spinners) are explicitly not pulses.
@@ -1679,6 +1825,9 @@ function checkHtmlPatterns(html) {
// the border-left regexes never see it).
findings.push(...scanCssTextForPseudoStripe(html));
// Side-tab accent stripe drawn as a single-edge inset box-shadow.
findings.push(...scanCssTextForInsetStripe(html));
// --- Layout ---
// Monotonous spacing
@@ -1757,6 +1906,9 @@ function checkHtmlPatterns(html) {
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(html));
// --- Dark glow / chromatic halo shadows ---
const glowHits = scanCssTextForGlow(html);
@@ -1789,23 +1941,47 @@ function checkHtmlPatterns(html) {
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
// Hairline stop shapes: the classic leading form (`<color> 1px,
// transparent 1px`) and the inverted end-of-tile form
// (`transparent calc(100% - 1px), <color> 1px`).
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
// Tiling cell: a background-size declaration with px values, or the
// background shorthand's `/ <size>` slot (only matched inside
// background values so border-radius slash syntax can't stand in).
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
if (!gridSizeRe.test(block)) continue;
let hairlineCount = 0;
let bgJoined = '';
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
const stops = bm[1].match(hairlineRe);
if (stops) hairlineCount += stops.length;
hairlineCount += (bm[1].match(hairlineRe) || []).length;
hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
bgJoined += bm[1] + ';';
}
if (hairlineCount >= 2) {
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
// Two hairline layers + any px tile = the classic two-axis grid.
// A single hairline layer only counts when tiled by a px pair cell
// (e.g. `/ 40px 40px`) — a page-scale repeating line field. Single
// hairlines tiled by percentage cells (`background-size: 25% 100%`)
// are structural rules on data-viz tracks/graphs and stay legal.
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
findings.push({
id: 'codex-grid-background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
});
break;
}
}
@@ -2005,18 +2181,21 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
// Tab-strip / selected-state context: a top or bottom accent on an element
// inside a tablist, nav, or aria-selected widget is an active-state
// underline affordance, not a decorative stripe.
// Selected-state / tab-strip context for accent stripes. Explicit tab
// semantics ([role=tablist]/[role=tab]) and active/current-item markers
// (aria-selected, aria-current, active/current/selected class hints)
// exempt the stripe as a selection indicator. A bare nav/menu ancestor
// deliberately does NOT — the same stripe repeated unconditionally on
// every menu item is decoration, not state.
function isTabContextElement(el) {
if (!el) return false;
try {
if (el.closest?.('[role="tablist"], [role="tab"], nav, [aria-selected]')) return true;
if (el.closest?.('[role="tablist"], [role="tab"], [aria-selected], [aria-current]')) return true;
} catch { /* selector engine differences — fall through to class scan */ }
let cur = el, depth = 0;
while (cur && cur.nodeType === 1 && depth < 6) {
const cls = String(cur.getAttribute?.('class') || cur.className || '');
if (/(?:^|[\s_-])tabs?(?:$|[\s_-])/i.test(cls)) return true;
if (/(?:^|[\s_-])(?:tabs?|active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
cur = cur.parentElement;
depth++;
}
@@ -2117,6 +2296,21 @@ function checkElementItalicSerifDOM(el) {
});
}
function domAccentDashPseudo(el) {
for (const which of ['::before', '::after']) {
let ps;
try { ps = getComputedStyle(el, which); } catch { continue; }
if (!ps || ps.content === 'none' || ps.content === '') continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
if (!bg || (bg.a ?? 1) < 0.1) continue;
if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
}
return false;
}
function checkElementHeroEyebrowDOM(el) {
const tag = el.tagName.toLowerCase();
if (tag !== 'h1') return [];
@@ -2135,6 +2329,7 @@ function checkElementHeroEyebrowDOM(el) {
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
siblingFontWeight: sibStyle.fontWeight || '',
siblingColor: sibStyle.color || '',
siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
});
}
@@ -3407,6 +3602,11 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
siblingFontWeight: fontWeightRaw || '',
siblingColor: colorRaw || '',
// Static cascade marks elements matched by a ::before/::after rule
// whose geometry is a short chromatic dash (css-cascade.mjs).
siblingHasAccentDashPseudo: typeof window.hasAccentDashPseudo === 'function'
? window.hasAccentDashPseudo(sibling)
: false,
});
}
+4 -1
View File
@@ -2,7 +2,7 @@ import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { scanCssTextForGlow, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
import { scanCssTextForGlow, scanCssTextForMarquee, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
@@ -334,6 +334,9 @@ const REGEX_ANALYZERS = [
const lines = content.substring(0, hits[0].index).split('\n');
return [finding('radial-halo', filePath, hits[0].snippet, lines.length)];
},
// Auto-scrolling marquees (<marquee> or infinite horizontal loop
// animations).
(content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)),
];
// ---------------------------------------------------------------------------
+37 -1
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
import { collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
@@ -840,6 +840,7 @@ class StaticDocument {
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
this._hoverStyleMap = new WeakMap();
this._accentDashPseudo = new WeakSet();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
@@ -882,6 +883,12 @@ class StaticDocument {
getHoverStyle(el) {
return this._hoverStyleMap.get(el.node) || null;
}
setAccentDashPseudo(node) {
this._accentDashPseudo.add(node);
}
hasAccentDashPseudo(el) {
return this._accentDashPseudo.has(el.node);
}
}
function makeStaticStyle(values = {}) {
@@ -898,6 +905,7 @@ function buildStaticWindow(staticDoc) {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
};
}
@@ -934,6 +942,7 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
// the cascade while the element is hovered (all resting rules still
// apply in that state).
const hoverSpecified = new Map();
const rootCustomProps = collectCssCustomProps(cssText);
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
@@ -949,6 +958,33 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
target: filePath,
}, () => {
for (const rule of rules) {
// ::before/::after rules can't join the element cascade (pseudo
// elements aren't DOM nodes), but one shape matters to the eyebrow
// check: the short chromatic "kicker dash" (content box 8-80px wide,
// 1-6px tall, accent-colored fill). Mark the base-selector matches
// so checkElementHeroEyebrow can see the dash.
if (!rule.isHover) {
const pm = rule.selector.match(/^(.+?)\s*::?(?:before|after)$/i);
if (pm) {
const decls = new Map();
for (const d of rule.declarations) decls.set(d.prop.toLowerCase(), d.value);
const w = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', rootCustomProps));
const h = cssLengthToPx(resolveVarRefs(decls.get('height') || decls.get('block-size') || '', rootCustomProps));
if (w != null && h != null && w >= 8 && w <= 80 && h >= 1 && h <= 6) {
const bgRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
const token = bgRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
const c = parseAnyColor(token ? token[0] : bgRaw);
if (c && (c.a ?? 1) >= 0.1 && Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= 30) {
try {
for (const node of modules.selectAll(pm[1], root.children || [])) {
staticDoc.setAccentDashPseudo(node);
}
} catch { /* unsupported base selector */ }
}
}
continue;
}
}
const matchSelector = rule.isHover ? rule.matchSelector : rule.selector;
if (!matchSelector) continue;
let matched;
+10 -1
View File
@@ -131,6 +131,15 @@ const ANTIPATTERNS = [
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'marquee',
category: 'slop',
name: 'Auto-scrolling marquee',
description:
'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.',
skillSection: 'Motion',
skillGuideline: 'auto-scrolling marquee',
},
{
id: 'icon-tile-stack',
category: 'slop',
@@ -436,7 +445,7 @@ const ANTIPATTERNS = [
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
+209 -13
View File
@@ -347,6 +347,7 @@ function checkHeroEyebrow(opts) {
siblingTag, siblingText, siblingTextTransform,
siblingFontSize, siblingLetterSpacing,
siblingFontWeight, siblingColor,
siblingHasAccentDashPseudo,
} = opts;
if (headingTag !== 'h1') return [];
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
@@ -378,11 +379,16 @@ function checkHeroEyebrow(opts) {
const weight = Number(siblingFontWeight) || 400;
const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
if (!isClassicTracked && !isAccentBold) return [];
// Branch C: dash-prefix eyebrow — sentence case, low tracking, regular
// weight, but announced by a short chromatic ::before/::after bar
// (the kicker dash). Same label-above-headline pattern, third styling.
const isDashPrefixed = !!siblingHasAccentDashPseudo;
if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return [];
const headingTextSnippet = (headingText || '').trim().slice(0, 60);
const eyebrowSnippet = text.slice(0, 40);
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix';
return [{
id: 'hero-eyebrow-chip',
snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
@@ -817,6 +823,137 @@ function scanCssTextForPseudoStripe(content) {
return findings;
}
// Side-tab stripe drawn as a single-edge inset box-shadow
// (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color):
// paints a bar along one edge with no border property involved, so the
// element-level border checks never see it. Selection-state indicators
// are exempt — an inset stripe on [aria-current] / .active / [role=tab]
// marks the selected item; the same stripe unconditionally on every item
// is decoration and flags.
function scanCssTextForInsetStripe(content) {
const customProps = collectCssCustomProps(content);
const findings = [];
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
// State/selection contexts: current-item markers, interaction states,
// explicit tab semantics.
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
if (/\[aria-(?:current|selected)/i.test(selector)) continue;
if (/\[role=["']?tab/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected|tabs?)(?![\w])/i.test(selector)) continue;
// Structural tags where a single-edge inset shadow is depth/quoting,
// not an accent stripe.
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
const decls = parseCssDeclBlock(m[2]);
const shadow = decls.get('box-shadow');
if (!shadow || !/\binset\b/i.test(shadow)) continue;
// Narrow fixed-width elements (logo marks, icon glyphs) use inset
// fills as artwork, not edge stripes. Stripe targets — cards, badges,
// menu items — are wider or leave width to layout.
const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps));
if (declaredWidth != null && declaredWidth <= 40) continue;
const value = resolveVarRefs(shadow, customProps);
for (const layer of value.split(/,(?![^(]*\))/)) {
if (!/\binset\b/i.test(layer)) continue;
const colorInfo = findShadowColor(layer);
// Unresolvable colors (currentColor, external vars): don't guess.
if (!colorInfo || !colorInfo.color) continue;
const c = colorInfo.color;
if ((c.a ?? 1) < 0.1) continue;
const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
if (chroma < 30) continue;
const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0;
if (blur !== 0 || sp !== 0) continue;
const ax = Math.abs(x), ay = Math.abs(y);
const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0);
if (!isStripe) continue;
if (seen.has(selector)) break;
seen.add(selector);
const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
});
break;
}
}
return findings;
}
// Collect @keyframes names whose body travels horizontally — the marquee
// loop. X travel is measured across every translateX/translate/translate3d
// X component in the body: a centered element animating something else
// keeps a constant -50% X (zero travel) and never qualifies, while a
// ticker moves from its resting position to a large offset. Keyframes
// with a single X sample that also vary scale/opacity read as pulses or
// breathes, not marquees.
function collectMarqueeKeyframes(content) {
const names = new Set();
const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
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++;
}
const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
re.lastIndex = i;
// Only percentage travel qualifies: a content marquee translates by a
// fraction of its own (unknown) track width, so generated tickers use
// -50% / -100%. Pixel-travel loops are bespoke product animations —
// sweeping playheads, progress indicators — not marquees.
const pct = [];
const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi;
let xm;
while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1]));
if (pct.length === 0) continue;
if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue;
// Implicit start: a lone declared X animates from the element's
// resting position, so its magnitude is the travel.
const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]);
if (travelPct >= 20) names.add(m[1]);
}
return names;
}
// Auto-scrolling marquee: a <marquee> element, or an infinite animation
// bound to a keyframe loop that travels a large horizontal distance.
// Rotation/opacity animations never qualify (no X travel); JS-driven
// carousels with user controls have no infinite CSS X-loop to match.
function scanCssTextForMarquee(content) {
const findings = [];
if (/<marquee\b/i.test(content)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
const decls = parseCssDeclBlock(m[2]);
for (const name of infiniteAnimationNames(decls)) {
if (!marqueeKeyframes.has(name)) continue;
const key = `${selector}${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
}
}
return findings;
}
// Collect @keyframes names and whether each one reads as a "pulse" —
// i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
// (spinners) are explicitly not pulses.
@@ -987,6 +1124,9 @@ function checkHtmlPatterns(html) {
// the border-left regexes never see it).
findings.push(...scanCssTextForPseudoStripe(html));
// Side-tab accent stripe drawn as a single-edge inset box-shadow.
findings.push(...scanCssTextForInsetStripe(html));
// --- Layout ---
// Monotonous spacing
@@ -1065,6 +1205,9 @@ function checkHtmlPatterns(html) {
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(html));
// --- Dark glow / chromatic halo shadows ---
const glowHits = scanCssTextForGlow(html);
@@ -1097,23 +1240,47 @@ function checkHtmlPatterns(html) {
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
// Hairline stop shapes: the classic leading form (`<color> 1px,
// transparent 1px`) and the inverted end-of-tile form
// (`transparent calc(100% - 1px), <color> 1px`).
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
// Tiling cell: a background-size declaration with px values, or the
// background shorthand's `/ <size>` slot (only matched inside
// background values so border-radius slash syntax can't stand in).
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
if (!gridSizeRe.test(block)) continue;
let hairlineCount = 0;
let bgJoined = '';
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
const stops = bm[1].match(hairlineRe);
if (stops) hairlineCount += stops.length;
hairlineCount += (bm[1].match(hairlineRe) || []).length;
hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
bgJoined += bm[1] + ';';
}
if (hairlineCount >= 2) {
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
// Two hairline layers + any px tile = the classic two-axis grid.
// A single hairline layer only counts when tiled by a px pair cell
// (e.g. `/ 40px 40px`) — a page-scale repeating line field. Single
// hairlines tiled by percentage cells (`background-size: 25% 100%`)
// are structural rules on data-viz tracks/graphs and stay legal.
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
findings.push({
id: 'codex-grid-background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
});
break;
}
}
@@ -1313,18 +1480,21 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
// Tab-strip / selected-state context: a top or bottom accent on an element
// inside a tablist, nav, or aria-selected widget is an active-state
// underline affordance, not a decorative stripe.
// Selected-state / tab-strip context for accent stripes. Explicit tab
// semantics ([role=tablist]/[role=tab]) and active/current-item markers
// (aria-selected, aria-current, active/current/selected class hints)
// exempt the stripe as a selection indicator. A bare nav/menu ancestor
// deliberately does NOT — the same stripe repeated unconditionally on
// every menu item is decoration, not state.
function isTabContextElement(el) {
if (!el) return false;
try {
if (el.closest?.('[role="tablist"], [role="tab"], nav, [aria-selected]')) return true;
if (el.closest?.('[role="tablist"], [role="tab"], [aria-selected], [aria-current]')) return true;
} catch { /* selector engine differences — fall through to class scan */ }
let cur = el, depth = 0;
while (cur && cur.nodeType === 1 && depth < 6) {
const cls = String(cur.getAttribute?.('class') || cur.className || '');
if (/(?:^|[\s_-])tabs?(?:$|[\s_-])/i.test(cls)) return true;
if (/(?:^|[\s_-])(?:tabs?|active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
cur = cur.parentElement;
depth++;
}
@@ -1425,6 +1595,21 @@ function checkElementItalicSerifDOM(el) {
});
}
function domAccentDashPseudo(el) {
for (const which of ['::before', '::after']) {
let ps;
try { ps = getComputedStyle(el, which); } catch { continue; }
if (!ps || ps.content === 'none' || ps.content === '') continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
if (!bg || (bg.a ?? 1) < 0.1) continue;
if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
}
return false;
}
function checkElementHeroEyebrowDOM(el) {
const tag = el.tagName.toLowerCase();
if (tag !== 'h1') return [];
@@ -1443,6 +1628,7 @@ function checkElementHeroEyebrowDOM(el) {
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
siblingFontWeight: sibStyle.fontWeight || '',
siblingColor: sibStyle.color || '',
siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
});
}
@@ -2715,6 +2901,11 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
siblingFontWeight: fontWeightRaw || '',
siblingColor: colorRaw || '',
// Static cascade marks elements matched by a ::before/::after rule
// whose geometry is a short chromatic dash (css-cascade.mjs).
siblingHasAccentDashPseudo: typeof window.hasAccentDashPseudo === 'function'
? window.hasAccentDashPseudo(sibling)
: false,
});
}
@@ -3454,6 +3645,11 @@ export {
scanCssTextForGlow,
scanCssTextForRadialHalo,
scanCssTextForPseudoStripe,
scanCssTextForInsetStripe,
scanCssTextForMarquee,
collectMarqueeKeyframes,
collectCssCustomProps,
cssLengthToPx,
scanCssTextForPulsingDot,
checkHtmlPatterns,
readOwnBackgroundColor,
+183
View File
@@ -13,11 +13,15 @@ import {
import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs';
import {
checkElementTextOverflowDOM,
checkHeroEyebrow,
checkHoverContrast,
checkHtmlPatterns,
checkPageTypography,
isScreenReaderOnlyTextStyle,
parseAnyColor,
parseColorMix,
scanCssTextForInsetStripe,
scanCssTextForMarquee,
scanCssTextForPseudoStripe,
scanCssTextForPulsingDot,
scanCssTextForRadialHalo,
@@ -1217,6 +1221,185 @@ describe('hover contrast + color-mix', () => {
});
});
// ---------------------------------------------------------------------------
// Auto-scrolling marquee
// ---------------------------------------------------------------------------
describe('marquee', () => {
test('flags infinite percent-travel X loop (implicit start)', () => {
const css = `
.ticker-track { display: flex; width: max-content; animation: ticker 25s linear infinite; }
@keyframes ticker { to { transform: translateX(-50%); } }
`;
const f = scanCssTextForMarquee(css);
expect(f).toHaveLength(1);
expect(f[0].id).toBe('marquee');
expect(f[0].snippet).toContain('.ticker-track');
});
test('flags <marquee> elements', () => {
const f = scanCssTextForMarquee('<div><marquee>sale sale sale</marquee></div>');
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('<marquee>');
});
test('skips centered elements animating other properties', () => {
const css = `
.toast { animation: rise 3s ease infinite; }
@keyframes rise { from { transform: translate(-50%, 8px); } to { transform: translate(-50%, 0); } }
`;
expect(scanCssTextForMarquee(css)).toHaveLength(0);
});
test('skips non-infinite slide-in animations', () => {
const css = `
.panel { animation: enter 0.4s ease; }
@keyframes enter { from { transform: translateX(-100%); } to { transform: translateX(0); } }
`;
expect(scanCssTextForMarquee(css)).toHaveLength(0);
});
test('skips px-travel sweeps (playheads, progress indicators)', () => {
const css = `
.wave-anim .playhead { animation: sweep 6s linear infinite; }
@keyframes sweep { from { transform: translateX(0); } to { transform: translateX(760px); } }
`;
expect(scanCssTextForMarquee(css)).toHaveLength(0);
});
test('skips rotation and pulse animations', () => {
const css = `
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.dot { animation: breathe 2s ease infinite; }
@keyframes breathe { 50% { transform: translateX(-50%) scale(1.1); opacity: 0.6; } }
`;
expect(scanCssTextForMarquee(css)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Inset box-shadow stripes (side-tab variant)
// ---------------------------------------------------------------------------
describe('inset box-shadow stripe', () => {
test('flags single-edge chromatic inset stripes on repeated items', () => {
const css = `
:root { --good: #16a34a; }
.flag-good { box-shadow: inset 0 3px 0 var(--good); }
.callout { box-shadow: inset 4px 0 0 #dc2626; }
`;
const f = scanCssTextForInsetStripe(css);
expect(f).toHaveLength(2);
expect(f[0].id).toBe('side-tab');
});
test('exempts current/selected-state indicators', () => {
const css = `
.section-link[aria-current="location"] { box-shadow: inset 3px 0 0 #ea580c; }
.item.active { box-shadow: inset 3px 0 0 #ea580c; }
[role="tab"][aria-selected="true"] { box-shadow: inset 0 -3px 0 #ea580c; }
.link:hover { box-shadow: inset 3px 0 0 #ea580c; }
`;
expect(scanCssTextForInsetStripe(css)).toHaveLength(0);
});
test('skips narrow glyphs, blurred/spread shadows, neutrals, and thick fills', () => {
const css = `
.brand-mark { width: 13px; box-shadow: inset 0 -7px 0 #2563eb; }
.card { box-shadow: inset 0 3px 6px rgba(0,0,0,.2); }
.row { box-shadow: inset 0 1px 0 #e5e7eb; }
.well { box-shadow: inset 0 20px 0 #dc2626; }
`;
expect(scanCssTextForInsetStripe(css)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Grid-line background variants (checkHtmlPatterns block scan)
// ---------------------------------------------------------------------------
describe('codex-grid-background variants', () => {
const grids = (html) => checkHtmlPatterns(html).filter(f => f.id === 'codex-grid-background');
test('flags two-axis inverted-calc hairlines with shorthand tile size', () => {
const css = `body { background:
linear-gradient(90deg, transparent calc(100% - 1px), oklch(0.84 0.015 255 / 0.4) 1px) 0 0 / 48px 48px,
linear-gradient(transparent calc(100% - 1px), oklch(0.84 0.015 255 / 0.4) 1px) 0 0 / 48px 48px,
#eef1f7; }`;
expect(grids(css)).toHaveLength(1);
});
test('flags single-axis hairline tiled by a px pair cell', () => {
const css = `body { background: linear-gradient(90deg, rgba(23,25,24,.035) 1px, transparent 1px) 0 0 / 40px 40px, #f4f1ea; }`;
const f = grids(css);
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('line-field');
});
test('keeps percent-tiled single hairlines (data-viz track rules) legal', () => {
const css = `.span-track { background-image: linear-gradient(90deg, #303532 1px, transparent 1px); background-size: 25% 100%; }`;
expect(grids(css)).toHaveLength(0);
});
test('classic two-axis background-size form still flags', () => {
const css = `.hero { background-image:
linear-gradient(#eee 1px, transparent 1px),
linear-gradient(90deg, #eee 1px, transparent 1px);
background-size: 24px 24px; }`;
expect(grids(css)).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Hero eyebrow: dash-prefix branch
// ---------------------------------------------------------------------------
describe('hero-eyebrow dash-prefix branch', () => {
const base = {
headingTag: 'h1',
headingText: 'Find the service that started it.',
headingFontSize: 72,
siblingTag: 'p',
siblingText: 'Distributed tracing for microservices',
siblingTextTransform: 'none',
siblingFontSize: 13,
siblingLetterSpacing: 0.26,
siblingFontWeight: '400',
siblingColor: 'rgb(120, 120, 110)',
};
test('flags sentence-case label with accent dash pseudo', () => {
const f = checkHeroEyebrow({ ...base, siblingHasAccentDashPseudo: true });
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('dash-prefix');
});
test('same label without the dash stays legal', () => {
expect(checkHeroEyebrow({ ...base, siblingHasAccentDashPseudo: false })).toHaveLength(0);
});
test('static engine resolves the dash through the cascade', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head><style>
:root { --signal: #e84a1c; }
.eyebrow { font-size: 13px; letter-spacing: .02em; color: #6f6a5f; }
.eyebrow::before { content: ""; width: 28px; height: 3px; background: var(--signal); }
h1 { font-size: 72px; }
</style></head><body><main>
<p class="eyebrow">Distributed tracing for microservices</p>
<h1>Find the service that started it.</h1>
<p>Body copy long enough to make this a real page for the scanners.</p>
</main></body></html>`,
}, async ({ file }) => {
const findings = await detectHtml(file);
const hits = findings.filter(f => f.antipattern === 'hero-eyebrow-chip');
expect(hits).toHaveLength(1);
expect(hits[0].snippet).toContain('dash-prefix');
});
});
});
// ---------------------------------------------------------------------------
// Pulsing status dots
// ---------------------------------------------------------------------------