From 7a99e1725d409be36565e3fd94601c171c35c06d Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 13 Jul 2026 13:09:54 -0700 Subject: [PATCH] =?UTF-8?q?detector:=20four=20human-review=20rules=20?= =?UTF-8?q?=E2=80=94=20nav-CTA=20oklch=20contrast,=20numbered=20section=20?= =?UTF-8?q?labels,=20floating=20side-tab=20stripes,=20repeated=20card=20te?= =?UTF-8?q?xt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four gaps found shipping in Opus 4.8 eval samples during human review: 1. low-contrast (extended): the browser adapters parsed text/own-bg colors with parseRgb only, so Chrome's oklch()-serialized computed colors silently skipped every contrast check — a flat dark-on-dark nav CTA (broader nav selector beating the button class) shipped at 1.5:1 undetected. checkElementColorsDOM and readOwnBackgroundColor now fall back to parseAnyColor. Near-threshold ratios print two decimals so a 4.497 finding no longer reads "4.5 needs 4.5". 2. NEW numbered-section-labels (slop, advisory): tiny (<=13px) styled numeric index labels riding beside section headings, repeated across 2+ sections with distinct indices. Sibling of repeated-section-kickers (which deliberately excludes bare numeric labels); handles both the direct prev-sibling shape and label-before-heading-wrapper shape. List/nav/table/card-item numbering is exempt. 3. side-tab (extended): the vertical pseudo-element stripe scan required the stripe to touch both corners (top/bottom 0 or height 100%), so a left accent bar inset a few px from each end evaded it; small end insets (<=20px each) now count. Added a browser-side pseudo-element check (getComputedStyle(el, '::before'/'::after')) since runtime- assigned custom-property colors are invisible to the text scanner. Selection-state exemptions stay as narrowed: only aria-selected=true / aria-current / active-class markers exempt, plus button/link affordances on the horizontal variant. 4. NEW repeated-container-text (quality): the same literal string (>=4 chars, contains letters) rendered 3+ times at 3+ structurally distinct positions inside one bordered/elevated container. Parallel/templated repetition (table cells, calendar grids, nav lists, identical sibling rows) never counts — structural signatures, not word lists. Verified: each rule fires on its repro sample via the file:// browser scan; clean eval samples add no new findings (the new low-contrast hits on other samples are genuine sub-AA oklch button pairs). Full test suite green; browser bundle regenerated; README/homepage rule counts bumped 49 -> 51 (docs-integrity test enforces them). Co-Authored-By: Claude Fable 5 --- README.md | 4 +- README.npm.md | 4 +- cli/engine/browser/injected/index.mjs | 17 + cli/engine/detect-antipatterns-browser.js | 401 +++++++++++++++++- .../engines/static-html/detect-html.mjs | 8 + cli/engine/registry/antipatterns.mjs | 18 + cli/engine/rules/checks.mjs | 377 +++++++++++++++- site/pages/index.astro | 4 +- tests/detect-antipatterns-fixtures.test.mjs | 33 ++ tests/detect-antipatterns.test.js | 76 ++++ .../antipatterns/numbered-section-labels.html | 151 +++++++ .../antipatterns/repeated-container-text.html | 129 ++++++ 12 files changed, 1208 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/antipatterns/numbered-section-labels.html create mode 100644 tests/fixtures/antipatterns/repeated-container-text.html diff --git a/README.md b/README.md index fe8ab687a..91b2cd6fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Impeccable -Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 49 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 51 deterministic detector rules for AI-generated frontend design. > **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style). @@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th Impeccable adds: - **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components. - **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. -- **49 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. +- **51 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. ## What's Included diff --git a/README.npm.md b/README.npm.md index 76bc01e1b..7d82057fd 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,6 +1,6 @@ # Impeccable CLI -Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 49 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. +Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 51 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. ## Quick Start @@ -56,7 +56,7 @@ npx impeccable detect --fast src/ **Quality**: tiny body text, cramped padding, long line lengths, small touch targets -49 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). +51 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). ## Exit Codes diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index fff811491..13342d599 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1477,6 +1477,7 @@ if (IS_BROWSER) { const findings = [ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), @@ -1526,6 +1527,22 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, sectionKickerFindings); } + const numberedLabelFindings = checkNumberedSectionLabelsDOM() + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (numberedLabelFindings.length > 0) { + pageLevelFindings.push(...numberedLabelFindings); + addBrowserFindings(groupMap, document.body, numberedLabelFindings); + } + + const repeatedTextFindings = checkRepeatedContainerTextDOM() + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (repeatedTextFindings.length > 0) { + pageLevelFindings.push(...repeatedTextFindings); + addBrowserFindings(groupMap, document.body, repeatedTextFindings); + } + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); for (const f of layoutFindings) { const el = f.el || document.body; diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index c3dba70f0..f4ceef75f 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -283,6 +283,17 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-labels', + category: 'slop', + scopes: ['type'], + severity: 'advisory', + name: 'Tiny numbered section labels', + description: + 'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, { id: 'numbered-section-markers', category: 'slop', @@ -465,6 +476,13 @@ const ANTIPATTERNS = [ skillSection: 'Layout & Space', skillGuideline: 'content wider than its container', }, + { + id: 'repeated-container-text', + category: 'quality', + name: 'Same text repeated inside one container', + description: + 'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.', + }, { id: 'clipped-overflow-container', category: 'quality', @@ -827,7 +845,11 @@ function checkColors(opts) { // like `text-paper/60` on `bg-ink` sections are the FP pattern. const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); if (!isAlphaFallbackFP) { - findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); + // Near-threshold ratios (e.g. 4.497) would round to the threshold + // itself at one decimal and read as "4.5 needs 4.5" — show two + // decimals there so the finding stays legible. + const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1); + findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); } } } @@ -1484,8 +1506,16 @@ function scanCssTextForPseudoStripe(content) { let edge = null; let thicknessPx = null; if (verticalCandidate) { + // Full-height stripes hug both corners; the "floating" variant backs + // off each end by a small inset (top/bottom a few px) so the bar + // clears the card's corners. Both read as the same side-tab accent — + // corner treatment is styling, not a different pattern. + const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps)); + const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps)); const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom)) - || /^100(?:\.0*)?%$/.test(heightValue); + || /^100(?:\.0*)?%$/.test(heightValue) + || (topPx != null && bottomPx != null + && topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20); if (fullHeight) { edge = isZeroOffset(offsets.left) ? 'left' : isZeroOffset(offsets.right) ? 'right' : null; @@ -2035,7 +2065,11 @@ function checkHtmlPatterns(html) { // `background: #abc`. Real browsers always decompose, so the fallback is // a no-op there. function readOwnBackgroundColor(el, computedStyle) { - const bg = parseRgb(computedStyle.backgroundColor); + // Real browsers keep wide-gamut/computed color functions (oklch(), oklab(), + // color-mix() results) in getComputedStyle output, which plain parseRgb + // misses — a flat oklch button background would silently skip every + // contrast check without the parseAnyColor fallback. + const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor); if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; const rawStyle = el.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); @@ -2230,6 +2264,77 @@ function checkElementBordersDOM(el) { }); } +// Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads +// stylesheet source, so a stripe whose color only exists at runtime (an +// inline per-card custom property, a JS-assigned var) or whose geometry +// resolves in layout never matches it. In a real browser the pseudo-element's +// computed style carries the actual used color and px geometry — check those +// directly. Gates mirror the text scanner: 3-12px thick, chromatic fill, +// spanning (nearly) the full edge; corner rounding on the host card is +// irrelevant. Exemptions stay narrow: structural/prose tags, real selection +// markers (isTabContextElement), and button/link affordances for the +// horizontal variant. +function checkElementPseudoStripeDOM(el) { + const tag = el.tagName.toLowerCase(); + if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return []; + if (el.closest?.('nav, blockquote, pre')) return []; + if (!isRenderedForBrowserRule(el)) return []; + const rect = el.getBoundingClientRect(); + if (rect.width < 40 || rect.height < 20) return []; + if (isTabContextElement(el)) return []; + + const findings = []; + 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 ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue; + const w = parseFloat(ps.width) || 0; + const h = parseFloat(ps.height) || 0; + if (!(w > 0 && h > 0)) continue; + + // Used values: for absolutely-positioned boxes the browser resolves + // both edge offsets after layout, so left/right (and top/bottom) are + // real distances, never "auto". + const left = parseFloat(ps.left); + const right = parseFloat(ps.right); + const top = parseFloat(ps.top); + const bottom = parseFloat(ps.bottom); + const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2; + + let edge = null; + let thickness = null; + // Vertical stripe: narrow box spanning (nearly) the full height of the + // host, hugging its left or right edge. "Nearly" tolerates the floating + // variant that backs off each end by a small inset. + if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) { + edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null; + thickness = w; + } + // Horizontal stripe riding the top or bottom edge. Button/link-styled + // hosts keep their underline affordances. + if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) { + const cls = String(el.getAttribute?.('class') || el.className || ''); + if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) { + edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null; + thickness = h; + } + } + if (!edge) 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) continue; + + findings.push({ + id: 'side-tab', + snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`, + }); + } + return findings; +} + function checkElementColorsDOM(el) { const tag = el.tagName.toLowerCase(); // No early SAFE_TAGS bail here — checkColors() does its own gating that @@ -2243,7 +2348,12 @@ function checkElementColorsDOM(el) { const effectiveBg = resolveBackground(el); return checkColors({ tag, - textColor: parseRgb(style.color), + // Chrome serializes computed colors specified in modern spaces as + // oklch()/oklab() strings; without the parseAnyColor fallback the text + // color comes back null and the low-contrast / gray-on-color checks + // 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), effectiveBg, effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), @@ -2800,6 +2910,143 @@ function checkRepeatedSectionKickersDOM() { return checkRepeatedSectionKickers({ candidates }); } +// ── Numbered section labels ───────────────────────────────────────────────── +// Sibling of the repeated-kicker rule: instead of a tracked uppercase word, +// the section scaffold is a tiny numeric index riding beside each section +// heading — bare and zero-padded, or an index joined to a short micro-label +// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit +// labels; this rule owns that shape. + +const NUMBERED_LABEL_TAGS = new Set(['span', 'p', 'div', 'small', 'em', 'strong', 'b']); + +// Returns { index, text } when the trimmed text reads as a section index +// label, else null. Two accepted shapes: a zero-padded/two-digit bare index, +// or a 1-2 digit index followed by a non-word separator and a short label. +function parseNumberedLabelText(rawText) { + const text = (rawText || '').replace(/\s+/g, ' ').trim(); + if (!text || text.length > 40) return null; + let m = /^(\d{2})$/.exec(text); + if (!m) m = /^(\d{1,2})\s*[^\w\s]\s*\S/.exec(text); + if (!m) return null; + const index = parseInt(m[1], 10); + if (!Number.isFinite(index) || index > 40) return null; + return { index, text }; +} + +function isNumberedSectionLabelCandidate(opts) { + const { + headingTag, headingText, headingFontSize, + labelTag, labelIndex, labelText, + labelFontSize, labelLetterSpacing, labelFontWeight, + labelFontFamily, labelTextTransform, labelColor, + } = opts; + if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; + if (!headingText || headingText.length < 3) return false; + if (!labelTag || !NUMBERED_LABEL_TAGS.has(labelTag)) return false; + if (labelIndex == null || !labelText) return false; + // Tiny rendered size is the tell — a display-scale section number is a + // different (deliberate) device and stays legal. + if (!(labelFontSize > 0 && labelFontSize <= 13)) return false; + // The heading must be visibly larger where we can resolve its size. + // clamp()/var() sizes come back unparseable (0) in the static engine — + // the remaining gates carry the check there. + if (headingFontSize > 0 && headingFontSize < labelFontSize * 1.3) return false; + // Deliberate micro-label styling separates the scaffold from incidental + // small text: mono face, bold weight, tracking, uppercase, or accent color. + const weight = Number(labelFontWeight) || 400; + return /mono/i.test(labelFontFamily || '') + || weight >= 600 + || (labelLetterSpacing || 0) >= 0.5 + || (labelTextTransform || '') === 'uppercase' + || isAccentColor(labelColor || ''); +} + +function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing) { + const candidates = []; + const seenLabels = new Set(); + for (const heading of doc.querySelectorAll('h2, h3, h4')) { + if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + // The index sits either directly before the heading, or before the + // wrapper the heading leads (label |

). + let label = heading.previousElementSibling; + if (!label) { + const parent = heading.parentElement; + const firstChild = parent?.children?.[0]; + if (firstChild === heading) label = parent.previousElementSibling; + } + if (!label || seenLabels.has(label)) continue; + if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue; + if (isRepeatedKickerCardContext(heading, label)) continue; + + const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim(); + const parsed = parseNumberedLabelText(labelText); + if (!parsed) continue; + + const headingStyle = getStyle(heading); + const labelStyle = getStyle(label); + const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim(); + const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0; + const labelFontSize = resolveLetterSpacing(labelStyle.fontSize || '', 16) || parseFloat(labelStyle.fontSize) || 0; + + if (!isNumberedSectionLabelCandidate({ + headingTag: heading.tagName.toLowerCase(), + headingText, + headingFontSize, + labelTag: label.tagName.toLowerCase(), + labelIndex: parsed.index, + labelText: parsed.text, + labelFontSize, + labelLetterSpacing: resolveLetterSpacing(labelStyle.letterSpacing || '', labelFontSize), + labelFontWeight: labelStyle.fontWeight || '', + labelFontFamily: labelStyle.fontFamily || '', + labelTextTransform: labelStyle.textTransform || '', + labelColor: labelStyle.color || '', + })) { + continue; + } + + seenLabels.add(label); + candidates.push({ + index: parsed.index, + labelText: parsed.text.slice(0, 24), + headingTag: heading.tagName.toLowerCase(), + headingText: headingText.replace(/^"|"$/g, '').slice(0, 60), + }); + } + return candidates; +} + +function checkNumberedSectionLabels(opts) { + const { candidates, minCount = 2 } = opts; + if (!Array.isArray(candidates) || candidates.length < minCount) return []; + // A repeated identical number is some other device; the scaffold counts up. + const distinctIndices = new Set(candidates.map(c => c.index)); + if (distinctIndices.size < 2) return []; + return candidates.map(candidate => ({ + id: 'numbered-section-labels', + snippet: `tiny numbered label "${candidate.labelText}" beside ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`, + })); +} + +function checkNumberedSectionLabelsFromDoc(doc, win) { + const candidates = collectNumberedSectionLabelCandidates( + doc, + (el) => win.getComputedStyle(el), + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, + ); + return checkNumberedSectionLabels({ candidates }); +} + +function checkNumberedSectionLabelsDOM() { + const candidates = collectNumberedSectionLabelCandidates( + document, + (el) => getComputedStyle(el), + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, + ); + return checkNumberedSectionLabels({ candidates }); +} + function checkElementMotionDOM(el) { const tag = el.tagName.toLowerCase(); if (SAFE_TAGS.has(tag)) return []; @@ -3894,6 +4141,135 @@ function checkPageLayout(doc, win) { return findings; } +// ── Repeated text inside one container ────────────────────────────────────── +// The same literal string rendered 3+ times in structurally different spots +// inside one bordered/elevated container — typically a status word wired +// into every slot of a card template. Legitimate repetition is structural: +// table columns, calendar grids, nav/menu lists, and templated sibling rows +// all repeat text in *parallel* positions, so occurrences whose element +// paths inside the container are identical (or live in dedicated repetition +// structures) never count. Only 3+ occurrences at 3+ distinct structural +// positions flag. + +const REPEATED_TEXT_SKIP_SELECTOR = [ + 'table', + 'select', + 'datalist', + 'nav', + 'menu', + '[role="navigation"]', + '[role="menu"]', + '[role="menubar"]', + '[role="listbox"]', + '[role="grid"]', + '[role="tablist"]', + '[role="radiogroup"]', + '[aria-hidden="true"]', +].join(','); + +const REPEATED_TEXT_CONTAINER_TAGS = new Set([ + 'div', 'section', 'article', 'aside', 'main', 'figure', 'form', 'fieldset', 'details', 'li', +]); + +// A container worth attributing text to: visibly bounded (border on most +// sides or an elevation shadow) and surface-like (radius or own background). +function isRepeatedTextContainer(style) { + if (!style) return false; + const hasShadow = !!(style.boxShadow && style.boxShadow !== 'none' && style.boxShadow !== ''); + const borderSides = ['Top', 'Right', 'Bottom', 'Left'] + .filter(side => (parseFloat(style[`border${side}Width`]) || 0) >= 1).length; + const hasBorder = borderSides >= 3; + const hasRadius = (parseFloat(style.borderRadius) || 0) > 0; + const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); + const hasBg = !!(bg && (bg.a ?? 1) > 0.1); + return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); +} + +function collectRepeatedContainerTextFindings(doc, getStyle, opts = {}) { + const isVisible = opts.isVisible || (() => true); + const findings = []; + + const containers = []; + const containerSet = new Set(); + for (const el of doc.querySelectorAll('*')) { + if (!REPEATED_TEXT_CONTAINER_TAGS.has(el.tagName.toLowerCase())) continue; + if (el.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue; + if (!isRepeatedTextContainer(getStyle(el))) continue; + containers.push(el); + containerSet.add(el); + } + + for (const container of containers) { + if (!isVisible(container)) continue; + const descendants = container.querySelectorAll('*'); + // Page-scale wrappers that merely happen to carry a background are not + // the "one card" this rule reasons about. + if (descendants.length > 250) continue; + + const groups = new Map(); + for (const d of descendants) { + // Attribute text to the innermost container only. + let anc = d.parentElement; + let ownedByInner = false; + while (anc && anc !== container) { + if (containerSet.has(anc)) { ownedByInner = true; break; } + anc = anc.parentElement; + } + if (ownedByInner) continue; + if (d.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue; + // Icon-font glyph names read as text but render as symbols. + if (/icon|material-symbols|(?:^|\s)fa[srlbd]?(?:\s|-|$)/i.test(String(d.getAttribute?.('class') || ''))) continue; + if (!isVisible(d)) continue; + + const direct = [...d.childNodes] + .filter(n => n.nodeType === 3) + .map(n => n.textContent) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + if (direct.length < 4 || direct.length > 48) continue; + if (!/[a-zA-Z]/.test(direct)) continue; + + // Structural signature: the element path from the occurrence up to + // the container. Parallel/templated repetition shares one signature. + const sig = []; + for (let cur = d; cur && cur !== container; cur = cur.parentElement) { + const cls = String(cur.getAttribute?.('class') || '') + .trim().split(/\s+/).filter(Boolean).sort().join('.'); + sig.push(cur.tagName.toLowerCase() + (cls ? `.${cls}` : '')); + } + if (!groups.has(direct)) groups.set(direct, []); + groups.get(direct).push(sig.join('>')); + } + + for (const [text, sigs] of groups) { + if (sigs.length < 3) continue; + if (new Set(sigs).size < 3) continue; + findings.push({ + id: 'repeated-container-text', + snippet: `"${text.slice(0, 40)}" rendered ${sigs.length}× in distinct spots inside ${classSelector(container)}`, + }); + } + } + return findings; +} + +function checkRepeatedContainerTextFromDoc(doc, win) { + return collectRepeatedContainerTextFindings( + doc, + (el) => win.getComputedStyle(el), + { isVisible: (el) => String(win.getComputedStyle(el).display || '') !== 'none' }, + ); +} + +function checkRepeatedContainerTextDOM() { + return collectRepeatedContainerTextFindings( + document, + (el) => getComputedStyle(el), + { isVisible: isRenderedForBrowserRule }, + ); +} + // ─── Cream / beige palette (the default "tasteful" AI surface) ──────────────── // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a // small warm tint (not white, not a strong color). The current reflex surface. @@ -5815,6 +6191,7 @@ if (IS_BROWSER) { const findings = [ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), @@ -5864,6 +6241,22 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, sectionKickerFindings); } + const numberedLabelFindings = checkNumberedSectionLabelsDOM() + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (numberedLabelFindings.length > 0) { + pageLevelFindings.push(...numberedLabelFindings); + addBrowserFindings(groupMap, document.body, numberedLabelFindings); + } + + const repeatedTextFindings = checkRepeatedContainerTextDOM() + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (repeatedTextFindings.length > 0) { + pageLevelFindings.push(...repeatedTextFindings); + addBrowserFindings(groupMap, document.body, repeatedTextFindings); + } + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); for (const f of layoutFindings) { const el = f.el || document.body; diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index a0f66c031..513b5928e 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -26,8 +26,10 @@ import { checkElementQuality, checkCreamPalette, checkHtmlPatterns, + checkNumberedSectionLabelsFromDoc, checkPageLayout, checkPageQualityFromDoc, + checkRepeatedContainerTextFromDoc, checkRepeatedSectionKickersFromDoc, resolveBackground, resolveBorderRadiusPx, @@ -202,6 +204,12 @@ async function detectHtml(filePath, options = {}) { for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) { findings.push(finding(f.id, filePath, f.snippet)); } + for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) { + findings.push(finding(f.id, filePath, f.snippet)); + } + for (const f of runPageCheck('repeated-container-text', () => checkRepeatedContainerTextFromDoc(document, window))) { + findings.push(finding(f.id, filePath, f.snippet)); + } for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) { findings.push(finding(f.id, filePath, f.snippet)); } diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index a05f0729f..98e6787dd 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -181,6 +181,17 @@ const ANTIPATTERNS = [ skillSection: 'Typography', skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', }, + { + id: 'numbered-section-labels', + category: 'slop', + scopes: ['type'], + severity: 'advisory', + name: 'Tiny numbered section labels', + description: + 'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.', + skillSection: 'Layout & Space', + skillGuideline: 'numbered section markers', + }, { id: 'numbered-section-markers', category: 'slop', @@ -363,6 +374,13 @@ const ANTIPATTERNS = [ skillSection: 'Layout & Space', skillGuideline: 'content wider than its container', }, + { + id: 'repeated-container-text', + category: 'quality', + name: 'Same text repeated inside one container', + description: + 'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.', + }, { id: 'clipped-overflow-container', category: 'quality', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index aec9f92fe..599f4c231 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -126,7 +126,11 @@ function checkColors(opts) { // like `text-paper/60` on `bg-ink` sections are the FP pattern. const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); if (!isAlphaFallbackFP) { - findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); + // Near-threshold ratios (e.g. 4.497) would round to the threshold + // itself at one decimal and read as "4.5 needs 4.5" — show two + // decimals there so the finding stays legible. + const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1); + findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); } } } @@ -783,8 +787,16 @@ function scanCssTextForPseudoStripe(content) { let edge = null; let thicknessPx = null; if (verticalCandidate) { + // Full-height stripes hug both corners; the "floating" variant backs + // off each end by a small inset (top/bottom a few px) so the bar + // clears the card's corners. Both read as the same side-tab accent — + // corner treatment is styling, not a different pattern. + const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps)); + const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps)); const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom)) - || /^100(?:\.0*)?%$/.test(heightValue); + || /^100(?:\.0*)?%$/.test(heightValue) + || (topPx != null && bottomPx != null + && topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20); if (fullHeight) { edge = isZeroOffset(offsets.left) ? 'left' : isZeroOffset(offsets.right) ? 'right' : null; @@ -1334,7 +1346,11 @@ function checkHtmlPatterns(html) { // `background: #abc`. Real browsers always decompose, so the fallback is // a no-op there. function readOwnBackgroundColor(el, computedStyle) { - const bg = parseRgb(computedStyle.backgroundColor); + // Real browsers keep wide-gamut/computed color functions (oklch(), oklab(), + // color-mix() results) in getComputedStyle output, which plain parseRgb + // misses — a flat oklch button background would silently skip every + // contrast check without the parseAnyColor fallback. + const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor); if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; const rawStyle = el.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); @@ -1529,6 +1545,77 @@ function checkElementBordersDOM(el) { }); } +// Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads +// stylesheet source, so a stripe whose color only exists at runtime (an +// inline per-card custom property, a JS-assigned var) or whose geometry +// resolves in layout never matches it. In a real browser the pseudo-element's +// computed style carries the actual used color and px geometry — check those +// directly. Gates mirror the text scanner: 3-12px thick, chromatic fill, +// spanning (nearly) the full edge; corner rounding on the host card is +// irrelevant. Exemptions stay narrow: structural/prose tags, real selection +// markers (isTabContextElement), and button/link affordances for the +// horizontal variant. +function checkElementPseudoStripeDOM(el) { + const tag = el.tagName.toLowerCase(); + if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return []; + if (el.closest?.('nav, blockquote, pre')) return []; + if (!isRenderedForBrowserRule(el)) return []; + const rect = el.getBoundingClientRect(); + if (rect.width < 40 || rect.height < 20) return []; + if (isTabContextElement(el)) return []; + + const findings = []; + 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 ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue; + const w = parseFloat(ps.width) || 0; + const h = parseFloat(ps.height) || 0; + if (!(w > 0 && h > 0)) continue; + + // Used values: for absolutely-positioned boxes the browser resolves + // both edge offsets after layout, so left/right (and top/bottom) are + // real distances, never "auto". + const left = parseFloat(ps.left); + const right = parseFloat(ps.right); + const top = parseFloat(ps.top); + const bottom = parseFloat(ps.bottom); + const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2; + + let edge = null; + let thickness = null; + // Vertical stripe: narrow box spanning (nearly) the full height of the + // host, hugging its left or right edge. "Nearly" tolerates the floating + // variant that backs off each end by a small inset. + if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) { + edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null; + thickness = w; + } + // Horizontal stripe riding the top or bottom edge. Button/link-styled + // hosts keep their underline affordances. + if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) { + const cls = String(el.getAttribute?.('class') || el.className || ''); + if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) { + edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null; + thickness = h; + } + } + if (!edge) 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) continue; + + findings.push({ + id: 'side-tab', + snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`, + }); + } + return findings; +} + function checkElementColorsDOM(el) { const tag = el.tagName.toLowerCase(); // No early SAFE_TAGS bail here — checkColors() does its own gating that @@ -1542,7 +1629,12 @@ function checkElementColorsDOM(el) { const effectiveBg = resolveBackground(el); return checkColors({ tag, - textColor: parseRgb(style.color), + // Chrome serializes computed colors specified in modern spaces as + // oklch()/oklab() strings; without the parseAnyColor fallback the text + // color comes back null and the low-contrast / gray-on-color checks + // 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), effectiveBg, effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), @@ -2099,6 +2191,143 @@ function checkRepeatedSectionKickersDOM() { return checkRepeatedSectionKickers({ candidates }); } +// ── Numbered section labels ───────────────────────────────────────────────── +// Sibling of the repeated-kicker rule: instead of a tracked uppercase word, +// the section scaffold is a tiny numeric index riding beside each section +// heading — bare and zero-padded, or an index joined to a short micro-label +// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit +// labels; this rule owns that shape. + +const NUMBERED_LABEL_TAGS = new Set(['span', 'p', 'div', 'small', 'em', 'strong', 'b']); + +// Returns { index, text } when the trimmed text reads as a section index +// label, else null. Two accepted shapes: a zero-padded/two-digit bare index, +// or a 1-2 digit index followed by a non-word separator and a short label. +function parseNumberedLabelText(rawText) { + const text = (rawText || '').replace(/\s+/g, ' ').trim(); + if (!text || text.length > 40) return null; + let m = /^(\d{2})$/.exec(text); + if (!m) m = /^(\d{1,2})\s*[^\w\s]\s*\S/.exec(text); + if (!m) return null; + const index = parseInt(m[1], 10); + if (!Number.isFinite(index) || index > 40) return null; + return { index, text }; +} + +function isNumberedSectionLabelCandidate(opts) { + const { + headingTag, headingText, headingFontSize, + labelTag, labelIndex, labelText, + labelFontSize, labelLetterSpacing, labelFontWeight, + labelFontFamily, labelTextTransform, labelColor, + } = opts; + if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; + if (!headingText || headingText.length < 3) return false; + if (!labelTag || !NUMBERED_LABEL_TAGS.has(labelTag)) return false; + if (labelIndex == null || !labelText) return false; + // Tiny rendered size is the tell — a display-scale section number is a + // different (deliberate) device and stays legal. + if (!(labelFontSize > 0 && labelFontSize <= 13)) return false; + // The heading must be visibly larger where we can resolve its size. + // clamp()/var() sizes come back unparseable (0) in the static engine — + // the remaining gates carry the check there. + if (headingFontSize > 0 && headingFontSize < labelFontSize * 1.3) return false; + // Deliberate micro-label styling separates the scaffold from incidental + // small text: mono face, bold weight, tracking, uppercase, or accent color. + const weight = Number(labelFontWeight) || 400; + return /mono/i.test(labelFontFamily || '') + || weight >= 600 + || (labelLetterSpacing || 0) >= 0.5 + || (labelTextTransform || '') === 'uppercase' + || isAccentColor(labelColor || ''); +} + +function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing) { + const candidates = []; + const seenLabels = new Set(); + for (const heading of doc.querySelectorAll('h2, h3, h4')) { + if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + // The index sits either directly before the heading, or before the + // wrapper the heading leads (label |

). + let label = heading.previousElementSibling; + if (!label) { + const parent = heading.parentElement; + const firstChild = parent?.children?.[0]; + if (firstChild === heading) label = parent.previousElementSibling; + } + if (!label || seenLabels.has(label)) continue; + if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue; + if (isRepeatedKickerCardContext(heading, label)) continue; + + const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim(); + const parsed = parseNumberedLabelText(labelText); + if (!parsed) continue; + + const headingStyle = getStyle(heading); + const labelStyle = getStyle(label); + const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim(); + const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0; + const labelFontSize = resolveLetterSpacing(labelStyle.fontSize || '', 16) || parseFloat(labelStyle.fontSize) || 0; + + if (!isNumberedSectionLabelCandidate({ + headingTag: heading.tagName.toLowerCase(), + headingText, + headingFontSize, + labelTag: label.tagName.toLowerCase(), + labelIndex: parsed.index, + labelText: parsed.text, + labelFontSize, + labelLetterSpacing: resolveLetterSpacing(labelStyle.letterSpacing || '', labelFontSize), + labelFontWeight: labelStyle.fontWeight || '', + labelFontFamily: labelStyle.fontFamily || '', + labelTextTransform: labelStyle.textTransform || '', + labelColor: labelStyle.color || '', + })) { + continue; + } + + seenLabels.add(label); + candidates.push({ + index: parsed.index, + labelText: parsed.text.slice(0, 24), + headingTag: heading.tagName.toLowerCase(), + headingText: headingText.replace(/^"|"$/g, '').slice(0, 60), + }); + } + return candidates; +} + +function checkNumberedSectionLabels(opts) { + const { candidates, minCount = 2 } = opts; + if (!Array.isArray(candidates) || candidates.length < minCount) return []; + // A repeated identical number is some other device; the scaffold counts up. + const distinctIndices = new Set(candidates.map(c => c.index)); + if (distinctIndices.size < 2) return []; + return candidates.map(candidate => ({ + id: 'numbered-section-labels', + snippet: `tiny numbered label "${candidate.labelText}" beside ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`, + })); +} + +function checkNumberedSectionLabelsFromDoc(doc, win) { + const candidates = collectNumberedSectionLabelCandidates( + doc, + (el) => win.getComputedStyle(el), + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, + ); + return checkNumberedSectionLabels({ candidates }); +} + +function checkNumberedSectionLabelsDOM() { + const candidates = collectNumberedSectionLabelCandidates( + document, + (el) => getComputedStyle(el), + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, + ); + return checkNumberedSectionLabels({ candidates }); +} + function checkElementMotionDOM(el) { const tag = el.tagName.toLowerCase(); if (SAFE_TAGS.has(tag)) return []; @@ -3193,6 +3422,135 @@ function checkPageLayout(doc, win) { return findings; } +// ── Repeated text inside one container ────────────────────────────────────── +// The same literal string rendered 3+ times in structurally different spots +// inside one bordered/elevated container — typically a status word wired +// into every slot of a card template. Legitimate repetition is structural: +// table columns, calendar grids, nav/menu lists, and templated sibling rows +// all repeat text in *parallel* positions, so occurrences whose element +// paths inside the container are identical (or live in dedicated repetition +// structures) never count. Only 3+ occurrences at 3+ distinct structural +// positions flag. + +const REPEATED_TEXT_SKIP_SELECTOR = [ + 'table', + 'select', + 'datalist', + 'nav', + 'menu', + '[role="navigation"]', + '[role="menu"]', + '[role="menubar"]', + '[role="listbox"]', + '[role="grid"]', + '[role="tablist"]', + '[role="radiogroup"]', + '[aria-hidden="true"]', +].join(','); + +const REPEATED_TEXT_CONTAINER_TAGS = new Set([ + 'div', 'section', 'article', 'aside', 'main', 'figure', 'form', 'fieldset', 'details', 'li', +]); + +// A container worth attributing text to: visibly bounded (border on most +// sides or an elevation shadow) and surface-like (radius or own background). +function isRepeatedTextContainer(style) { + if (!style) return false; + const hasShadow = !!(style.boxShadow && style.boxShadow !== 'none' && style.boxShadow !== ''); + const borderSides = ['Top', 'Right', 'Bottom', 'Left'] + .filter(side => (parseFloat(style[`border${side}Width`]) || 0) >= 1).length; + const hasBorder = borderSides >= 3; + const hasRadius = (parseFloat(style.borderRadius) || 0) > 0; + const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); + const hasBg = !!(bg && (bg.a ?? 1) > 0.1); + return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); +} + +function collectRepeatedContainerTextFindings(doc, getStyle, opts = {}) { + const isVisible = opts.isVisible || (() => true); + const findings = []; + + const containers = []; + const containerSet = new Set(); + for (const el of doc.querySelectorAll('*')) { + if (!REPEATED_TEXT_CONTAINER_TAGS.has(el.tagName.toLowerCase())) continue; + if (el.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue; + if (!isRepeatedTextContainer(getStyle(el))) continue; + containers.push(el); + containerSet.add(el); + } + + for (const container of containers) { + if (!isVisible(container)) continue; + const descendants = container.querySelectorAll('*'); + // Page-scale wrappers that merely happen to carry a background are not + // the "one card" this rule reasons about. + if (descendants.length > 250) continue; + + const groups = new Map(); + for (const d of descendants) { + // Attribute text to the innermost container only. + let anc = d.parentElement; + let ownedByInner = false; + while (anc && anc !== container) { + if (containerSet.has(anc)) { ownedByInner = true; break; } + anc = anc.parentElement; + } + if (ownedByInner) continue; + if (d.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue; + // Icon-font glyph names read as text but render as symbols. + if (/icon|material-symbols|(?:^|\s)fa[srlbd]?(?:\s|-|$)/i.test(String(d.getAttribute?.('class') || ''))) continue; + if (!isVisible(d)) continue; + + const direct = [...d.childNodes] + .filter(n => n.nodeType === 3) + .map(n => n.textContent) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + if (direct.length < 4 || direct.length > 48) continue; + if (!/[a-zA-Z]/.test(direct)) continue; + + // Structural signature: the element path from the occurrence up to + // the container. Parallel/templated repetition shares one signature. + const sig = []; + for (let cur = d; cur && cur !== container; cur = cur.parentElement) { + const cls = String(cur.getAttribute?.('class') || '') + .trim().split(/\s+/).filter(Boolean).sort().join('.'); + sig.push(cur.tagName.toLowerCase() + (cls ? `.${cls}` : '')); + } + if (!groups.has(direct)) groups.set(direct, []); + groups.get(direct).push(sig.join('>')); + } + + for (const [text, sigs] of groups) { + if (sigs.length < 3) continue; + if (new Set(sigs).size < 3) continue; + findings.push({ + id: 'repeated-container-text', + snippet: `"${text.slice(0, 40)}" rendered ${sigs.length}× in distinct spots inside ${classSelector(container)}`, + }); + } + } + return findings; +} + +function checkRepeatedContainerTextFromDoc(doc, win) { + return collectRepeatedContainerTextFindings( + doc, + (el) => win.getComputedStyle(el), + { isVisible: (el) => String(win.getComputedStyle(el).display || '') !== 'none' }, + ); +} + +function checkRepeatedContainerTextDOM() { + return collectRepeatedContainerTextFindings( + document, + (el) => getComputedStyle(el), + { isVisible: isRenderedForBrowserRule }, + ); +} + // ─── Cream / beige palette (the default "tasteful" AI surface) ──────────────── // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a // small warm tint (not white, not a strong color). The current reflex surface. @@ -3680,6 +4038,17 @@ export { isRepeatedKickerCandidate, collectRepeatedSectionKickerCandidates, checkRepeatedSectionKickersDOM, + parseNumberedLabelText, + isNumberedSectionLabelCandidate, + collectNumberedSectionLabelCandidates, + checkNumberedSectionLabels, + checkNumberedSectionLabelsFromDoc, + checkNumberedSectionLabelsDOM, + isRepeatedTextContainer, + collectRepeatedContainerTextFindings, + checkRepeatedContainerTextFromDoc, + checkRepeatedContainerTextDOM, + checkElementPseudoStripeDOM, checkElementMotionDOM, checkElementGlowDOM, checkElementAIPaletteDOM, diff --git a/site/pages/index.astro b/site/pages/index.astro index 4078ffb11..3c8fca572 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -521,7 +521,7 @@ import '../styles/testimonials.css';
06

Block slop before it ships.

-

A detector you can wire into PR checks. 49 deterministic rules, no LLM, exit codes the build can read.

+

A detector you can wire into PR checks. 51 deterministic rules, no LLM, exit codes the build can read.

@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
  • CLI for CI - npx impeccable detect src/ in a PR check. 49 deterministic rules. JSON output, exit codes for build gates. + npx impeccable detect src/ in a PR check. 51 deterministic rules. JSON output, exit codes for build gates. View on npm →
  • diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 597adc45d..0ecaf992d 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -375,6 +375,39 @@ describe('detectHtml — static HTML/CSS fixtures', () => { ); assert.match(numbered[0].snippet, /01, 02, 03/); }); + + it('numbered-section-labels: tiny repeated index labels flag, deliberate/list/card numbering passes', async () => { + const f = await detectHtml(path.join(FIXTURES, 'numbered-section-labels.html')); + const labels = f.filter(r => r.antipattern === 'numbered-section-labels'); + const snippets = labels.map(r => r.snippet).join(' | '); + assert.equal( + labels.length, + 4, + `expected 4 numbered-label findings, got ${labels.length}: ${snippets}` + ); + for (const heading of ['Alpha ships first', 'Beta earns trust', 'Gamma holds the line', 'Delta closes the loop']) { + assert.match(snippets, new RegExp(heading), `expected label beside "${heading}" to flag`); + } + for (const heading of ['Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu']) { + assert.doesNotMatch(snippets, new RegExp(heading), `label beside "${heading}" should pass`); + } + }); + + it('repeated-container-text: same string in 3+ distinct slots of one card flags; structural repetition passes', async () => { + const f = await detectHtml(path.join(FIXTURES, 'repeated-container-text.html')); + const repeats = f.filter(r => r.antipattern === 'repeated-container-text'); + const snippets = repeats.map(r => r.snippet).join(' | '); + assert.equal( + repeats.length, + 2, + `expected 2 repeated-text findings, got ${repeats.length}: ${snippets}` + ); + assert.match(snippets, /Suspended.*3×|Suspended" rendered 3/, 'expected the 3-slot status word to flag'); + assert.match(snippets, /Unavailable" rendered 4/, 'expected the 4-slot status word to flag'); + for (const passText of ['Rolled back', 'On schedule', 'Overview page', 'Standby mode', 'Open slot', 'Rescheduled', '2026']) { + assert.doesNotMatch(snippets, new RegExp(passText), `"${passText}" should pass`); + } + }); }); describe('detectHtml — icon-tile-stack', () => { diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 4edb8d164..fab7a7650 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -12,9 +12,12 @@ import { } from '../cli/engine/detect-antipatterns.mjs'; import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs'; import { + checkColors, checkElementTextOverflowDOM, checkHeroEyebrow, checkHoverContrast, + checkNumberedSectionLabels, + parseNumberedLabelText, checkHtmlPatterns, checkPageTypography, isScreenReaderOnlyTextStyle, @@ -1022,6 +1025,22 @@ describe('side-tab — pseudo-element stripe variant', () => { expect(scanCssTextForPseudoStripe(css)).toHaveLength(1); }); + test('detects floating stripe inset a few px from each end', () => { + // The evasion shape from human review: same left-edge accent bar, but + // backed off the card's corners by a small top/bottom inset so it never + // touches an edge (and needs no corner rounding to read as a side tab). + const css = '.row::before { content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px; border-radius: 3px; background: oklch(0.65 0.19 15); }'; + const f = scanCssTextForPseudoStripe(css); + expect(f).toHaveLength(1); + expect(f[0].id).toBe('side-tab'); + expect(f[0].snippet).toContain('(left: 0)'); + }); + + test('skips deeply-inset partial rail (not an edge-spanning stripe)', () => { + const css = '.rail::before { position: absolute; left: 0; top: 40px; bottom: 40px; width: 4px; background: #3b82f6; }'; + expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); + }); + test('unresolvable custom-property color errs toward detection', () => { const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--from-external-sheet); }'; expect(scanCssTextForPseudoStripe(css)).toHaveLength(1); @@ -1106,6 +1125,63 @@ describe('side-tab — pseudo-element stripe variant', () => { }); }); +// --------------------------------------------------------------------------- +// Low contrast — modern computed-color serializations (browser adapter path) +// --------------------------------------------------------------------------- + +describe('checkColors — oklch computed colors', () => { + test('flat dark-on-dark oklch CTA pair parses and fails contrast', () => { + // Real browsers hand back oklch() strings from getComputedStyle for + // colors authored in modern spaces; the adapters must not lose them. + const textColor = parseAnyColor('oklch(0.34 0.01 70)'); + const bgColor = parseAnyColor('oklch(0.22 0.01 70)'); + expect(textColor).toBeTruthy(); + expect(bgColor).toBeTruthy(); + const f = checkColors({ + tag: 'a', + textColor, + bgColor, + effectiveBg: bgColor, + effectiveBgStops: null, + fontSize: 14.4, + fontWeight: 500, + hasDirectText: true, + isEmojiOnly: false, + bgClip: '', + bgImage: '', + classList: 'btn btn-primary', + }); + expect(f.some(r => r.id === 'low-contrast')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Numbered section labels — pure helpers +// --------------------------------------------------------------------------- + +describe('numbered-section-labels — pure helpers', () => { + test('parseNumberedLabelText accepts zero-padded and separator forms only', () => { + expect(parseNumberedLabelText('01')).toEqual({ index: 1, text: '01' }); + expect(parseNumberedLabelText('12')).toEqual({ index: 12, text: '12' }); + expect(parseNumberedLabelText('04 / rollout')).toMatchObject({ index: 4 }); + expect(parseNumberedLabelText('6 · getting started')).toMatchObject({ index: 6 }); + expect(parseNumberedLabelText('7')).toBeNull(); + expect(parseNumberedLabelText('Step 3')).toBeNull(); + expect(parseNumberedLabelText('12 minute read')).toBeNull(); + expect(parseNumberedLabelText('50% off everything')).toBeNull(); + expect(parseNumberedLabelText('')).toBeNull(); + }); + + test('checkNumberedSectionLabels needs 2+ candidates with 2+ distinct indices', () => { + const candidate = (index) => ({ index, labelText: String(index).padStart(2, '0'), headingTag: 'h2', headingText: 'Heading' }); + expect(checkNumberedSectionLabels({ candidates: [candidate(1)] })).toHaveLength(0); + expect(checkNumberedSectionLabels({ candidates: [candidate(1), candidate(1)] })).toHaveLength(0); + const flagged = checkNumberedSectionLabels({ candidates: [candidate(1), candidate(2)] }); + expect(flagged).toHaveLength(2); + expect(flagged[0].id).toBe('numbered-section-labels'); + }); +}); + // --------------------------------------------------------------------------- // Radial-gradient background halo // --------------------------------------------------------------------------- diff --git a/tests/fixtures/antipatterns/numbered-section-labels.html b/tests/fixtures/antipatterns/numbered-section-labels.html new file mode 100644 index 000000000..a14495a9c --- /dev/null +++ b/tests/fixtures/antipatterns/numbered-section-labels.html @@ -0,0 +1,151 @@ + + + + + Numbered Section Labels Fixture + + + + + +
    + +
    + 01 +

    Alpha ships first

    +

    Direct previous-sibling label: tiny, mono, bold, zero-padded index.

    +
    + +
    + 02 +

    Beta earns trust

    +

    Second section repeating the same index scaffold.

    +
    + +
    + 03 +
    +

    Gamma holds the line

    +

    Label precedes the wrapper; the heading leads the wrapper.

    +
    +
    + +
    + 04 / ROLLOUT +

    Delta closes the loop

    +

    Index joined to a tracked micro-label by a separator glyph.

    +
    + +
    + + +
    + +
    + 05 +

    Epsilon reads large

    +

    16px index is a deliberate display device, not a micro label.

    +
    + +
    + 12 · minute read +

    Zeta stays plain

    +

    Parses as an index but carries no micro-label styling: regular weight, neutral color, no tracking, serif body face.

    +
    + +
    + Step 6 +

    Eta walks steps

    +

    Word-first label is not a numeric index.

    +
    + +
    + 7 +

    Theta counts casually

    +

    A bare unpadded single digit is ordinary list numbering.

    +
    + +
      +
    1. + 08 +

      Iota lives in a list

      +
    2. +
    3. + 09 +

      Kappa lives in a list too

      +
    4. +
    + +
    + 10 +

    Lambda sits in a card

    +

    Per-card indices are item numbering, not section scaffolding.

    +
    + + + +
    + + + diff --git a/tests/fixtures/antipatterns/repeated-container-text.html b/tests/fixtures/antipatterns/repeated-container-text.html new file mode 100644 index 000000000..fa04b91a6 --- /dev/null +++ b/tests/fixtures/antipatterns/repeated-container-text.html @@ -0,0 +1,129 @@ + + + + + Repeated Container Text Fixture + + + + + + + +
    +

    Museumplein departure

    +
    Suspended
    +
    Suspended
    +

    This service is Suspended until further notice.

    +
    + + +
    +

    Evening show

    + +
    Unavailable
    + +

    Currently Unavailable at this venue.

    +
    + + + + +
    +

    Deploy history

    + + + + +
    build 12Rolled back
    build 13Rolled back
    build 14Rolled back
    +
    + + +
    +

    Departures

    +
    On schedule
    +
    On schedule
    +
    On schedule
    +
    + + +
    +

    Sections

    + +
    + + +
    +

    Toggles

    +
    Off
    +
    Off
    +
    Off
    +
    + + +
    +

    Scores

    +
    2026
    +
    2026
    +
    2026
    +
    + + +
    Standby mode
    +
    Standby mode
    +
    Standby mode
    + + +
    +

    Availability

    +
    +
    Open slot
    +
    Open slot
    +
    Open slot
    +
    +
    + + +
    +

    Corner case

    +
    Rescheduled
    +
    Rescheduled
    +
    + + +