From ea44f514f977389b65a10805ff0f52bac0e78bd1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 12 Jul 2026 18:38:59 -0700 Subject: [PATCH] detector: grid-background variants, dash-prefix eyebrow, marquee rule, inset-shadow stripes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- cli/engine/detect-antipatterns-browser.js | 228 ++++++++++++++++-- cli/engine/engines/regex/detect-text.mjs | 5 +- .../engines/static-html/css-cascade.mjs | 38 ++- cli/engine/registry/antipatterns.mjs | 11 +- cli/engine/rules/checks.mjs | 222 ++++++++++++++++- tests/detect-antipatterns.test.js | 183 ++++++++++++++ 6 files changed, 657 insertions(+), 30 deletions(-) diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 24e211241..1926f4672 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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 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 (/ 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 ( 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 (` 1px, + // transparent 1px`) and the inverted end-of-tile form + // (`transparent calc(100% - 1px), 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 `/ ` 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, }); } diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 4c279a468..81c30ab99 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -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 ( or infinite horizontal loop + // animations). + (content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)), ]; // --------------------------------------------------------------------------- diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index 60a9d57c9..5ba7ffd43 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -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; diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index f6dd0a9a9..a05f0729f 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -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', }, diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 6e084eed7..4e20b080f 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -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 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 (/ 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 ( 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 (` 1px, + // transparent 1px`) and the inverted end-of-tile form + // (`transparent calc(100% - 1px), 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 `/ ` 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, diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index f728c73e1..69415e5d0 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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 elements', () => { + const f = scanCssTextForMarquee('
sale sale sale
'); + expect(f).toHaveLength(1); + expect(f[0].snippet).toContain(''); + }); + + 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': `
+

Distributed tracing for microservices

+

Find the service that started it.

+

Body copy long enough to make this a real page for the scanners.

+
`, + }, 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 // ---------------------------------------------------------------------------