From ed7a6fbe4e02f4b3626b521cae88dc30a75f8d81 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 15 Jul 2026 14:53:03 -0700 Subject: [PATCH] detector: text-occlusion + first-viewport-column-overflow (57 -> 59) Two browser-engine quality rules, both warning severity. text-occlusion / element-overlap fires on three shapes: an opaque decorated box painted over a text element (elementFromPoint confirms real coverage, box >= 30%), one text run buried under another when at least one side is a positioned layer (text >= 45%, so line-box leading bleed between stacked flow blocks does not count), and an inline element whose opaque fill leaks past its line onto a neighbour (the class-name collision bug). A large headline whose edge overhangs a bounded content card is caught as an element collision even when the text stays on top. Gradient scrims, decorative SVG emblems, fixed/sticky overlays, floats, and raw image backdrops (contrast territory, deduped against the pixel low-contrast rule) are exempt. first-viewport-column-overflow fires when a multi-column opening section runs one column past 140% of the viewport while a sibling fits inside one screen, the stretched-hero signature. Single-column pages and full-page heroes with no fitting sibling are exempt. Validated: fires on the diagnosed repros, clean across a 60-sample sweep. Fixtures + browser tests added. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- README.npm.md | 4 +- cli/engine/browser/injected/index.mjs | 14 + cli/engine/detect-antipatterns-browser.js | 379 ++++++++++++++++++ cli/engine/registry/antipatterns.mjs | 18 + cli/engine/rules/checks.mjs | 351 ++++++++++++++++ site/pages/index.astro | 4 +- tests/detect-antipatterns-browser.test.mjs | 23 ++ .../first-viewport-column-overflow.html | 55 +++ .../fixtures/antipatterns/text-occlusion.html | 81 ++++ 10 files changed, 927 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/antipatterns/first-viewport-column-overflow.html create mode 100644 tests/fixtures/antipatterns/text-occlusion.html diff --git a/README.md b/README.md index 621021161..e1af0319b 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 57 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 59 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. -- **57 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. +- **59 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 ece80d914..ec00a1d2a 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 57 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 59 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 -57 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). +59 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 6adf7c8fb..a6126fc57 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1563,6 +1563,20 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); } + // Text occlusion / element overlap (browser-only: needs real layout + + // elementFromPoint to confirm what actually paints on top) + const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type)); + for (const f of occlusionFindings) { + addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); + } + + // First-viewport column overflow — the stretched-hero signature + // (browser-only: needs real layout for the content-extent math) + const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type)); + for (const f of colOverflowFindings) { + addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); + } + // Page-level quality checks (headings, etc.) const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 453b260c8..033573e36 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -406,6 +406,24 @@ const ANTIPATTERNS = [ description: 'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.', }, + { + id: 'text-occlusion', + category: 'quality', + scopes: ['layout'], + name: 'Text occluded by an overlapping element', + description: + 'Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.', + skillSection: 'Layout & Space', + }, + { + id: 'first-viewport-column-overflow', + category: 'quality', + scopes: ['layout'], + name: 'One column stretches the first viewport', + description: + 'A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.', + skillSection: 'Layout & Space', + }, { id: 'gray-on-color', category: 'quality', @@ -5439,6 +5457,353 @@ function checkEdgeFlushCardsDOM() { return findings; } +// --------------------------------------------------------------------------- +// Text occlusion / element overlap (browser-only) +// --------------------------------------------------------------------------- + +// An opaque decorated box: a near-solid background fill or two-plus visible +// borders make it hide whatever sits behind it. Gradient / image fills are +// deliberately excluded — a scrim gradient over hero imagery is a contrast +// layer, not an occluder, and belongs to the pixel low-contrast rule. +function isOpaqueDecoratedBox(cs) { + if (!cs) return false; + const bg = parseAnyColor(cs.backgroundColor || ''); + if (bg && (bg.a ?? 1) > 0.6) return true; + const borderSides = ['Top', 'Right', 'Bottom', 'Left'].filter((side) => { + if ((parseFloat(cs[`border${side}Width`]) || 0) <= 0) return false; + const bc = parseAnyColor(cs[`border${side}Color`] || ''); + return bc && (bc.a ?? 1) > 0.3; + }).length; + return borderSides >= 2; +} + +// Is this element lifted out of normal flow into a layer that can cover +// siblings? Two normal-flow blocks stacked vertically cannot truly hide each +// other's ink — an overlap between their rects is line-box bleed from tight +// leading (a display headline reaching up over the line before it), not +// occlusion. Only out-of-flow positioning (absolute / fixed / sticky) moves an +// element off its own row onto the pixels of another; an in-place transform or +// relative nudge on a display headline does not. +function isLayeredElement(el) { + for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) { + const pos = String(getComputedStyle(cur).position || 'static'); + if (pos === 'absolute' || pos === 'fixed' || pos === 'sticky') return true; + } + return false; +} + +function elementDirectText(el) { + let t = ''; + for (const node of el.childNodes || []) { + if (node.nodeType === 3) t += node.textContent; + } + return t.trim(); +} + +// Rendered gate that, unlike isRenderedForBrowserRule, does NOT exempt +// aria-hidden subtrees: a decorative aria-hidden box still paints on screen +// and can still visually cover real text. +function isPaintedForOcclusion(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.05) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + +// Detects text that is actually painted UNDER an opaque box or another text +// run (the reader can't read it), plus two structural overlap tells the +// elementFromPoint probe can't reach: a large headline whose edge tucks behind +// an opaque card, and an inline element whose leaked padding-box (a common +// class-name-collision bug) covers a sibling. +// +// The occlusion probe is viewport-bound: elementFromPoint only answers for the +// scan's current viewport (scroll 0), so the ground-truth paths cover the +// first-viewport composition where collisions matter most. The inline-leak +// path is pure geometry and runs anywhere on the page. +const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']); + +function checkTextOcclusionDOM() { + const findings = []; + const seenVictims = new Set(); + const vw = window.innerWidth || 1280; + const vh = window.innerHeight || 800; + + const isFloated = (cs) => { + const f = String(cs.cssFloat || cs.float || 'none').toLowerCase(); + return f === 'left' || f === 'right'; + }; + const isMarqueeish = (el, cs) => { + if (el.tagName === 'MARQUEE') return true; + const ident = `${el.getAttribute?.('class') || ''} ${el.getAttribute?.('id') || ''}`; + if (/\b(marquee|ticker|scroller|carousel|conveyor)\b/i.test(ident)) return true; + const anim = String(cs.animationName || '').toLowerCase(); + return /marquee|ticker|scroll/.test(anim); + }; + // A fixed or sticky overlay (status bar, toolbar, sticky header) floats above + // scrolling content by design — whatever sits under it at rest scrolls clear, + // so it is not occluding the page. + const isPinnedOverlay = (el) => { + for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) { + const pos = String(getComputedStyle(cur).position || 'static'); + if (pos === 'fixed' || pos === 'sticky') return true; + } + return false; + }; + + // Collect renderable text owners in / near the first viewport for the + // elementFromPoint probe. SVG counts too. + const textEls = []; + for (const el of document.querySelectorAll('body *')) { + const tag = el.tagName.toLowerCase(); + if (OCCLUSION_TEXT_SKIP_TAGS.has(tag)) continue; + const inSvg = !!el.closest('svg'); + if (inSvg && tag !== 'text') continue; + const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el); + if (text.length < 2) continue; + if (!isPaintedForOcclusion(el)) continue; + let rect; try { rect = el.getBoundingClientRect(); } catch { continue; } + if (rect.width < 6 || rect.height < 6) continue; + // Viewport-bound probe: keep text whose box overlaps the live viewport. + if (rect.bottom <= 0 || rect.top >= vh) continue; + textEls.push({ el, rect, text, inSvg }); + } + + for (const victim of textEls) { + const { el, rect, text } = victim; + if (seenVictims.has(el)) continue; + const style = getComputedStyle(el); + if (isScreenReaderOnlyTextStyle(style, { width: rect.width, height: rect.height, clientWidth: el.clientWidth, clientHeight: el.clientHeight })) continue; + + const cols = Math.max(6, Math.min(30, Math.round(rect.width / 12))); + const rows = Math.max(1, Math.min(4, Math.round(rect.height / 14))); + let total = 0; + let occluded = 0; + let occluderEl = null; + let occluderKind = ''; + for (let i = 0; i < cols; i++) { + const x = rect.left + rect.width * ((i + 0.5) / cols); + if (x < 1 || x > vw - 1) continue; + for (let j = 0; j < rows; j++) { + const y = rect.top + rect.height * ((j + 0.5) / rows); + if (y < 1 || y > vh - 1) continue; + total++; + const top = document.elementFromPoint(x, y); + if (!top) continue; + // Text visible here: the probe returns the text itself, a descendant, + // or one of its ancestors (the text's own container / background). + if (top === el || el.contains(top) || top.contains(el)) continue; + const topCs = getComputedStyle(top); + if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue; + const topTag = top.tagName.toLowerCase(); + // Text sitting under a raw image/video is contrast territory (deduped + // against the pixel low-contrast rule); leave those alone here. + if (['img', 'video', 'canvas', 'picture'].includes(topTag)) continue; + const topHasText = elementDirectText(top).length > 0 || !!top.closest('svg'); + if (isOpaqueDecoratedBox(topCs)) { + occluded++; + if (!occluderEl) { occluderEl = top; occluderKind = 'box'; } + } else if (topHasText) { + occluded++; + if (!occluderEl) { occluderEl = top; occluderKind = 'text'; } + } + } + } + if (total === 0 || !occluderEl) continue; + const occFrac = occluded / total; + // A solid box's paint fills its rect, so box coverage is real at a lower + // bar. Text coverage rides on elementFromPoint returning the occluder's box + // (line box / container), which can exceed its actual glyph ink, so the + // text bar is higher — partial overlaps below it are crowding, not burial. + if (occFrac < (occluderKind === 'text' ? 0.45 : 0.3)) continue; + + // (i) Substantial occlusion: a real slab of the text is behind something. + if (occluderKind === 'text') { + // Two SVG texts inside the same emblem (concentric arcs, monogram) are one + // decorative unit, not a collision. + const victimSvg = el.closest('svg'); + const occSvg = occluderEl.closest('svg'); + if (victimSvg && occSvg && victimSvg === occSvg) continue; + // Both sides in plain flow: the overlap is line-box bleed from tight + // leading (a big headline reaching up over its own eyebrow), not one text + // run painted over another. + if (!isLayeredElement(el) && !isLayeredElement(occluderEl)) continue; + } + seenVictims.add(el); + findings.push({ + el, + type: 'text-occlusion', + detail: `${classSelector(el)} "${text.slice(0, 24)}" is ${Math.round(occFrac * 100)}% covered by ${occluderKind === 'text' ? 'overlapping text' : 'an opaque element'} (${classSelector(occluderEl)})`, + }); + } + + // (ii) Headline overhanging an opaque card: a display-scale line whose bulk + // sits outside a bounded content card but whose edge clips into it. The text + // may still paint on top and stay readable, but the two layers were dropped + // on the same pixels — a placement collision, not a composition. + const cards = []; + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg')) continue; + if (!isPaintedForOcclusion(el)) continue; + const cs = getComputedStyle(el); + const bg = parseAnyColor(cs.backgroundColor || ''); + const bgImg = cs.backgroundImage || ''; + if (!bg || (bg.a ?? 1) <= 0.7) continue; + if (bgImg && bgImg !== 'none' && /(gradient|url)\(/i.test(bgImg)) continue; + const hasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((s) => (parseFloat(cs[`border${s}Width`]) || 0) > 0); + const hasShadow = cs.boxShadow && cs.boxShadow !== 'none'; + if (!hasBorder && !hasShadow) continue; + if (isPinnedOverlay(el)) continue; + let cr; try { cr = el.getBoundingClientRect(); } catch { continue; } + if (cr.width < 100 || cr.width > 0.8 * vw || cr.height < 60) continue; + cards.push({ el, rect: cr }); + } + for (const victim of textEls) { + const { el, rect, text } = victim; + if (seenVictims.has(el)) continue; + const style = getComputedStyle(el); + if ((parseFloat(style.fontSize) || 16) < 40) continue; + let lineHeight = parseFloat(style.lineHeight); + if (!Number.isFinite(lineHeight)) lineHeight = (parseFloat(style.fontSize) || 16) * 1.2; + const centerX = rect.left + rect.width / 2; + for (const card of cards) { + if (card.el === el || el.contains(card.el) || card.el.contains(el)) continue; + const ix = Math.max(0, Math.min(rect.right, card.rect.right) - Math.max(rect.left, card.rect.left)); + const iy = Math.max(0, Math.min(rect.bottom, card.rect.bottom) - Math.max(rect.top, card.rect.top)); + if (ix < 8 || iy < 0.5 * lineHeight) continue; + // The headline's bulk must sit outside the card — only its edge clips in. + if (centerX >= card.rect.left && centerX <= card.rect.right) continue; + if (ix > 0.5 * rect.width) continue; + seenVictims.add(el); + findings.push({ + el, + type: 'text-occlusion', + detail: `${classSelector(el)} "${text.slice(0, 24)}" overhangs ${classSelector(card.el)} by ${Math.round(ix)}px — the headline and the card collide`, + }); + break; + } + } + + // (iii) Inline padding leak: an inline element with an opaque background and + // large vertical padding paints a filled block whose padding-box overflows + // its line (inline padding reserves no vertical space), so the fill lands on + // the content above and below instead of enclosing its own text. The + // canonical bug is a class-name collision that hands a decorative marker a + // payoff card's padding. The tell is a rendered height several times the line + // height, which distinguishes the leak from a padded inline highlight. + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg')) continue; + if (!isPaintedForOcclusion(el)) continue; + const cs = getComputedStyle(el); + if (cs.display !== 'inline') continue; + const bg = parseAnyColor(cs.backgroundColor || ''); + if (!bg || (bg.a ?? 1) <= 0.6) continue; + const padTop = parseFloat(cs.paddingTop) || 0; + const padBottom = parseFloat(cs.paddingBottom) || 0; + if (padTop + padBottom < 24) continue; + let rect; try { rect = el.getBoundingClientRect(); } catch { continue; } + if (rect.width < 12 || rect.height < 24) continue; + const fontSize = parseFloat(cs.fontSize) || 16; + let lineHeight = parseFloat(cs.lineHeight); + if (!Number.isFinite(lineHeight)) lineHeight = fontSize * 1.4; + // The padding box has to overflow the line by a clear margin — a padded + // inline highlight sits at roughly one line height, the leak at several. + if (rect.height < 2.2 * lineHeight) continue; + if (seenVictims.has(el)) continue; + // Name a neighbour the fill lands on, if one is nearby (paint state aside, + // reveal-on-scroll siblings still occupy the space it covers). + let overlaps = null; + for (const other of el.parentElement ? el.parentElement.children : []) { + if (other === el || el.contains(other) || other.contains(el)) continue; + if (getComputedStyle(other).display === 'none') continue; + const oRect = other.getBoundingClientRect(); + const ix = Math.max(0, Math.min(rect.right, oRect.right) - Math.max(rect.left, oRect.left)); + const iy = Math.max(0, Math.min(rect.bottom, oRect.bottom) - Math.max(rect.top, oRect.top)); + if (ix > 4 && iy > 4 && (other.textContent || '').trim().length > 0) { overlaps = other; break; } + } + seenVictims.add(el); + findings.push({ + el, + type: 'text-occlusion', + detail: `${classSelector(el)} is an inline element whose opaque fill leaks ${Math.round(rect.height)}px past its line${overlaps ? ` onto ${classSelector(overlaps)}` : ''}`, + }); + } + + return findings; +} + +// --------------------------------------------------------------------------- +// First-viewport column overflow — the stretched-hero signature (browser-only) +// --------------------------------------------------------------------------- + +// A multi-column composition that opens the page (grid/flex with two or more +// side-by-side columns, each a real share of the width) where one column's +// content runs far past the fold while its sibling fits inside a single +// viewport. The row stretches to the tall column, so the short one floats in a +// screen-and-a-half of dead space and the fold falls deep inside a single +// section. Single-column pages and full-page heroes (no sibling column) are +// exempt because there is no fitting sibling to contrast against. +function checkFirstViewportColumnOverflowDOM() { + const findings = []; + const vw = window.innerWidth || 1280; + const vh = window.innerHeight || 800; + const isMultiCol = (s) => /(^|inline-)(grid|flex)$/.test(String(s.display || '')); + + for (const el of document.querySelectorAll('body *')) { + const style = getComputedStyle(el); + if (!isMultiCol(style)) continue; + let rect; try { rect = el.getBoundingClientRect(); } catch { continue; } + if (rect.width < 0.5 * vw) continue; + const pageTop = rect.top + (window.scrollY || 0); + const pageBottom = pageTop + rect.height; + // The fold must fall inside this container: it opens within the first + // viewport and runs past it. + if (pageTop >= vh * 0.9 || pageBottom <= vh) continue; + + // Direct children that read as side-by-side columns: a real width share, + // not full-bleed (stacked single column), sharing the container's top row. + const cols = []; + for (const child of el.children) { + const cs = getComputedStyle(child); + if (cs.display === 'none') continue; + if (String(cs.position || '') === 'absolute' || String(cs.position || '') === 'fixed') continue; + let cr; try { cr = child.getBoundingClientRect(); } catch { continue; } + const wShare = cr.width / rect.width; + if (wShare < 0.25 || wShare > 0.9) continue; + if (cr.height < 40) continue; + // Content extent: how far the child's own content actually reaches, + // independent of a stretched row height. + let contentBottom = cr.top; + for (const d of child.querySelectorAll('*')) { + const ds = getComputedStyle(d); + if (ds.position === 'absolute' || ds.position === 'fixed') continue; + if (ds.display === 'none' || ds.visibility === 'hidden') continue; + let dr; try { dr = d.getBoundingClientRect(); } catch { continue; } + if (dr.width > 0 && dr.height > 0) contentBottom = Math.max(contentBottom, dr.bottom); + } + cols.push({ child, top: cr.top, contentH: contentBottom - cr.top }); + } + if (cols.length < 2) continue; + // Side-by-side: the two candidate columns must share the top row. + cols.sort((a, b) => b.contentH - a.contentH); + const tall = cols[0]; + const shortest = cols[cols.length - 1]; + if (Math.abs(tall.top - shortest.top) > 0.25 * vh) continue; + if (tall.contentH <= vh * 1.4) continue; + if (shortest.contentH > vh) continue; + + findings.push({ + el, + type: 'first-viewport-column-overflow', + detail: `${classSelector(el)} opens the page with one column running ${Math.round(tall.contentH / vh * 100)}% of the viewport tall while a sibling fits in ${Math.round(shortest.contentH / vh * 100)}% — the fold falls deep inside the section`, + }); + } + return findings; +} + // --- cli/engine/browser/injected/index.mjs --- const IS_BROWSER = typeof window !== 'undefined'; @@ -7005,6 +7370,20 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); } + // Text occlusion / element overlap (browser-only: needs real layout + + // elementFromPoint to confirm what actually paints on top) + const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type)); + for (const f of occlusionFindings) { + addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); + } + + // First-viewport column overflow — the stretched-hero signature + // (browser-only: needs real layout for the content-extent math) + const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type)); + for (const f of colOverflowFindings) { + addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); + } + // Page-level quality checks (headings, etc.) const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index da1709acc..b85f7e62f 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -304,6 +304,24 @@ const ANTIPATTERNS = [ description: 'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.', }, + { + id: 'text-occlusion', + category: 'quality', + scopes: ['layout'], + name: 'Text occluded by an overlapping element', + description: + 'Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.', + skillSection: 'Layout & Space', + }, + { + id: 'first-viewport-column-overflow', + category: 'quality', + scopes: ['layout'], + name: 'One column stretches the first viewport', + description: + 'A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.', + skillSection: 'Layout & Space', + }, { id: 'gray-on-color', category: 'quality', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 81890b2a6..3fdd6b2dd 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -4668,6 +4668,353 @@ function checkEdgeFlushCardsDOM() { return findings; } +// --------------------------------------------------------------------------- +// Text occlusion / element overlap (browser-only) +// --------------------------------------------------------------------------- + +// An opaque decorated box: a near-solid background fill or two-plus visible +// borders make it hide whatever sits behind it. Gradient / image fills are +// deliberately excluded — a scrim gradient over hero imagery is a contrast +// layer, not an occluder, and belongs to the pixel low-contrast rule. +function isOpaqueDecoratedBox(cs) { + if (!cs) return false; + const bg = parseAnyColor(cs.backgroundColor || ''); + if (bg && (bg.a ?? 1) > 0.6) return true; + const borderSides = ['Top', 'Right', 'Bottom', 'Left'].filter((side) => { + if ((parseFloat(cs[`border${side}Width`]) || 0) <= 0) return false; + const bc = parseAnyColor(cs[`border${side}Color`] || ''); + return bc && (bc.a ?? 1) > 0.3; + }).length; + return borderSides >= 2; +} + +// Is this element lifted out of normal flow into a layer that can cover +// siblings? Two normal-flow blocks stacked vertically cannot truly hide each +// other's ink — an overlap between their rects is line-box bleed from tight +// leading (a display headline reaching up over the line before it), not +// occlusion. Only out-of-flow positioning (absolute / fixed / sticky) moves an +// element off its own row onto the pixels of another; an in-place transform or +// relative nudge on a display headline does not. +function isLayeredElement(el) { + for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) { + const pos = String(getComputedStyle(cur).position || 'static'); + if (pos === 'absolute' || pos === 'fixed' || pos === 'sticky') return true; + } + return false; +} + +function elementDirectText(el) { + let t = ''; + for (const node of el.childNodes || []) { + if (node.nodeType === 3) t += node.textContent; + } + return t.trim(); +} + +// Rendered gate that, unlike isRenderedForBrowserRule, does NOT exempt +// aria-hidden subtrees: a decorative aria-hidden box still paints on screen +// and can still visually cover real text. +function isPaintedForOcclusion(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.05) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + +// Detects text that is actually painted UNDER an opaque box or another text +// run (the reader can't read it), plus two structural overlap tells the +// elementFromPoint probe can't reach: a large headline whose edge tucks behind +// an opaque card, and an inline element whose leaked padding-box (a common +// class-name-collision bug) covers a sibling. +// +// The occlusion probe is viewport-bound: elementFromPoint only answers for the +// scan's current viewport (scroll 0), so the ground-truth paths cover the +// first-viewport composition where collisions matter most. The inline-leak +// path is pure geometry and runs anywhere on the page. +const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']); + +function checkTextOcclusionDOM() { + const findings = []; + const seenVictims = new Set(); + const vw = window.innerWidth || 1280; + const vh = window.innerHeight || 800; + + const isFloated = (cs) => { + const f = String(cs.cssFloat || cs.float || 'none').toLowerCase(); + return f === 'left' || f === 'right'; + }; + const isMarqueeish = (el, cs) => { + if (el.tagName === 'MARQUEE') return true; + const ident = `${el.getAttribute?.('class') || ''} ${el.getAttribute?.('id') || ''}`; + if (/\b(marquee|ticker|scroller|carousel|conveyor)\b/i.test(ident)) return true; + const anim = String(cs.animationName || '').toLowerCase(); + return /marquee|ticker|scroll/.test(anim); + }; + // A fixed or sticky overlay (status bar, toolbar, sticky header) floats above + // scrolling content by design — whatever sits under it at rest scrolls clear, + // so it is not occluding the page. + const isPinnedOverlay = (el) => { + for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) { + const pos = String(getComputedStyle(cur).position || 'static'); + if (pos === 'fixed' || pos === 'sticky') return true; + } + return false; + }; + + // Collect renderable text owners in / near the first viewport for the + // elementFromPoint probe. SVG counts too. + const textEls = []; + for (const el of document.querySelectorAll('body *')) { + const tag = el.tagName.toLowerCase(); + if (OCCLUSION_TEXT_SKIP_TAGS.has(tag)) continue; + const inSvg = !!el.closest('svg'); + if (inSvg && tag !== 'text') continue; + const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el); + if (text.length < 2) continue; + if (!isPaintedForOcclusion(el)) continue; + let rect; try { rect = el.getBoundingClientRect(); } catch { continue; } + if (rect.width < 6 || rect.height < 6) continue; + // Viewport-bound probe: keep text whose box overlaps the live viewport. + if (rect.bottom <= 0 || rect.top >= vh) continue; + textEls.push({ el, rect, text, inSvg }); + } + + for (const victim of textEls) { + const { el, rect, text } = victim; + if (seenVictims.has(el)) continue; + const style = getComputedStyle(el); + if (isScreenReaderOnlyTextStyle(style, { width: rect.width, height: rect.height, clientWidth: el.clientWidth, clientHeight: el.clientHeight })) continue; + + const cols = Math.max(6, Math.min(30, Math.round(rect.width / 12))); + const rows = Math.max(1, Math.min(4, Math.round(rect.height / 14))); + let total = 0; + let occluded = 0; + let occluderEl = null; + let occluderKind = ''; + for (let i = 0; i < cols; i++) { + const x = rect.left + rect.width * ((i + 0.5) / cols); + if (x < 1 || x > vw - 1) continue; + for (let j = 0; j < rows; j++) { + const y = rect.top + rect.height * ((j + 0.5) / rows); + if (y < 1 || y > vh - 1) continue; + total++; + const top = document.elementFromPoint(x, y); + if (!top) continue; + // Text visible here: the probe returns the text itself, a descendant, + // or one of its ancestors (the text's own container / background). + if (top === el || el.contains(top) || top.contains(el)) continue; + const topCs = getComputedStyle(top); + if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue; + const topTag = top.tagName.toLowerCase(); + // Text sitting under a raw image/video is contrast territory (deduped + // against the pixel low-contrast rule); leave those alone here. + if (['img', 'video', 'canvas', 'picture'].includes(topTag)) continue; + const topHasText = elementDirectText(top).length > 0 || !!top.closest('svg'); + if (isOpaqueDecoratedBox(topCs)) { + occluded++; + if (!occluderEl) { occluderEl = top; occluderKind = 'box'; } + } else if (topHasText) { + occluded++; + if (!occluderEl) { occluderEl = top; occluderKind = 'text'; } + } + } + } + if (total === 0 || !occluderEl) continue; + const occFrac = occluded / total; + // A solid box's paint fills its rect, so box coverage is real at a lower + // bar. Text coverage rides on elementFromPoint returning the occluder's box + // (line box / container), which can exceed its actual glyph ink, so the + // text bar is higher — partial overlaps below it are crowding, not burial. + if (occFrac < (occluderKind === 'text' ? 0.45 : 0.3)) continue; + + // (i) Substantial occlusion: a real slab of the text is behind something. + if (occluderKind === 'text') { + // Two SVG texts inside the same emblem (concentric arcs, monogram) are one + // decorative unit, not a collision. + const victimSvg = el.closest('svg'); + const occSvg = occluderEl.closest('svg'); + if (victimSvg && occSvg && victimSvg === occSvg) continue; + // Both sides in plain flow: the overlap is line-box bleed from tight + // leading (a big headline reaching up over its own eyebrow), not one text + // run painted over another. + if (!isLayeredElement(el) && !isLayeredElement(occluderEl)) continue; + } + seenVictims.add(el); + findings.push({ + el, + type: 'text-occlusion', + detail: `${classSelector(el)} "${text.slice(0, 24)}" is ${Math.round(occFrac * 100)}% covered by ${occluderKind === 'text' ? 'overlapping text' : 'an opaque element'} (${classSelector(occluderEl)})`, + }); + } + + // (ii) Headline overhanging an opaque card: a display-scale line whose bulk + // sits outside a bounded content card but whose edge clips into it. The text + // may still paint on top and stay readable, but the two layers were dropped + // on the same pixels — a placement collision, not a composition. + const cards = []; + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg')) continue; + if (!isPaintedForOcclusion(el)) continue; + const cs = getComputedStyle(el); + const bg = parseAnyColor(cs.backgroundColor || ''); + const bgImg = cs.backgroundImage || ''; + if (!bg || (bg.a ?? 1) <= 0.7) continue; + if (bgImg && bgImg !== 'none' && /(gradient|url)\(/i.test(bgImg)) continue; + const hasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((s) => (parseFloat(cs[`border${s}Width`]) || 0) > 0); + const hasShadow = cs.boxShadow && cs.boxShadow !== 'none'; + if (!hasBorder && !hasShadow) continue; + if (isPinnedOverlay(el)) continue; + let cr; try { cr = el.getBoundingClientRect(); } catch { continue; } + if (cr.width < 100 || cr.width > 0.8 * vw || cr.height < 60) continue; + cards.push({ el, rect: cr }); + } + for (const victim of textEls) { + const { el, rect, text } = victim; + if (seenVictims.has(el)) continue; + const style = getComputedStyle(el); + if ((parseFloat(style.fontSize) || 16) < 40) continue; + let lineHeight = parseFloat(style.lineHeight); + if (!Number.isFinite(lineHeight)) lineHeight = (parseFloat(style.fontSize) || 16) * 1.2; + const centerX = rect.left + rect.width / 2; + for (const card of cards) { + if (card.el === el || el.contains(card.el) || card.el.contains(el)) continue; + const ix = Math.max(0, Math.min(rect.right, card.rect.right) - Math.max(rect.left, card.rect.left)); + const iy = Math.max(0, Math.min(rect.bottom, card.rect.bottom) - Math.max(rect.top, card.rect.top)); + if (ix < 8 || iy < 0.5 * lineHeight) continue; + // The headline's bulk must sit outside the card — only its edge clips in. + if (centerX >= card.rect.left && centerX <= card.rect.right) continue; + if (ix > 0.5 * rect.width) continue; + seenVictims.add(el); + findings.push({ + el, + type: 'text-occlusion', + detail: `${classSelector(el)} "${text.slice(0, 24)}" overhangs ${classSelector(card.el)} by ${Math.round(ix)}px — the headline and the card collide`, + }); + break; + } + } + + // (iii) Inline padding leak: an inline element with an opaque background and + // large vertical padding paints a filled block whose padding-box overflows + // its line (inline padding reserves no vertical space), so the fill lands on + // the content above and below instead of enclosing its own text. The + // canonical bug is a class-name collision that hands a decorative marker a + // payoff card's padding. The tell is a rendered height several times the line + // height, which distinguishes the leak from a padded inline highlight. + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg')) continue; + if (!isPaintedForOcclusion(el)) continue; + const cs = getComputedStyle(el); + if (cs.display !== 'inline') continue; + const bg = parseAnyColor(cs.backgroundColor || ''); + if (!bg || (bg.a ?? 1) <= 0.6) continue; + const padTop = parseFloat(cs.paddingTop) || 0; + const padBottom = parseFloat(cs.paddingBottom) || 0; + if (padTop + padBottom < 24) continue; + let rect; try { rect = el.getBoundingClientRect(); } catch { continue; } + if (rect.width < 12 || rect.height < 24) continue; + const fontSize = parseFloat(cs.fontSize) || 16; + let lineHeight = parseFloat(cs.lineHeight); + if (!Number.isFinite(lineHeight)) lineHeight = fontSize * 1.4; + // The padding box has to overflow the line by a clear margin — a padded + // inline highlight sits at roughly one line height, the leak at several. + if (rect.height < 2.2 * lineHeight) continue; + if (seenVictims.has(el)) continue; + // Name a neighbour the fill lands on, if one is nearby (paint state aside, + // reveal-on-scroll siblings still occupy the space it covers). + let overlaps = null; + for (const other of el.parentElement ? el.parentElement.children : []) { + if (other === el || el.contains(other) || other.contains(el)) continue; + if (getComputedStyle(other).display === 'none') continue; + const oRect = other.getBoundingClientRect(); + const ix = Math.max(0, Math.min(rect.right, oRect.right) - Math.max(rect.left, oRect.left)); + const iy = Math.max(0, Math.min(rect.bottom, oRect.bottom) - Math.max(rect.top, oRect.top)); + if (ix > 4 && iy > 4 && (other.textContent || '').trim().length > 0) { overlaps = other; break; } + } + seenVictims.add(el); + findings.push({ + el, + type: 'text-occlusion', + detail: `${classSelector(el)} is an inline element whose opaque fill leaks ${Math.round(rect.height)}px past its line${overlaps ? ` onto ${classSelector(overlaps)}` : ''}`, + }); + } + + return findings; +} + +// --------------------------------------------------------------------------- +// First-viewport column overflow — the stretched-hero signature (browser-only) +// --------------------------------------------------------------------------- + +// A multi-column composition that opens the page (grid/flex with two or more +// side-by-side columns, each a real share of the width) where one column's +// content runs far past the fold while its sibling fits inside a single +// viewport. The row stretches to the tall column, so the short one floats in a +// screen-and-a-half of dead space and the fold falls deep inside a single +// section. Single-column pages and full-page heroes (no sibling column) are +// exempt because there is no fitting sibling to contrast against. +function checkFirstViewportColumnOverflowDOM() { + const findings = []; + const vw = window.innerWidth || 1280; + const vh = window.innerHeight || 800; + const isMultiCol = (s) => /(^|inline-)(grid|flex)$/.test(String(s.display || '')); + + for (const el of document.querySelectorAll('body *')) { + const style = getComputedStyle(el); + if (!isMultiCol(style)) continue; + let rect; try { rect = el.getBoundingClientRect(); } catch { continue; } + if (rect.width < 0.5 * vw) continue; + const pageTop = rect.top + (window.scrollY || 0); + const pageBottom = pageTop + rect.height; + // The fold must fall inside this container: it opens within the first + // viewport and runs past it. + if (pageTop >= vh * 0.9 || pageBottom <= vh) continue; + + // Direct children that read as side-by-side columns: a real width share, + // not full-bleed (stacked single column), sharing the container's top row. + const cols = []; + for (const child of el.children) { + const cs = getComputedStyle(child); + if (cs.display === 'none') continue; + if (String(cs.position || '') === 'absolute' || String(cs.position || '') === 'fixed') continue; + let cr; try { cr = child.getBoundingClientRect(); } catch { continue; } + const wShare = cr.width / rect.width; + if (wShare < 0.25 || wShare > 0.9) continue; + if (cr.height < 40) continue; + // Content extent: how far the child's own content actually reaches, + // independent of a stretched row height. + let contentBottom = cr.top; + for (const d of child.querySelectorAll('*')) { + const ds = getComputedStyle(d); + if (ds.position === 'absolute' || ds.position === 'fixed') continue; + if (ds.display === 'none' || ds.visibility === 'hidden') continue; + let dr; try { dr = d.getBoundingClientRect(); } catch { continue; } + if (dr.width > 0 && dr.height > 0) contentBottom = Math.max(contentBottom, dr.bottom); + } + cols.push({ child, top: cr.top, contentH: contentBottom - cr.top }); + } + if (cols.length < 2) continue; + // Side-by-side: the two candidate columns must share the top row. + cols.sort((a, b) => b.contentH - a.contentH); + const tall = cols[0]; + const shortest = cols[cols.length - 1]; + if (Math.abs(tall.top - shortest.top) > 0.25 * vh) continue; + if (tall.contentH <= vh * 1.4) continue; + if (shortest.contentH > vh) continue; + + findings.push({ + el, + type: 'first-viewport-column-overflow', + detail: `${classSelector(el)} opens the page with one column running ${Math.round(tall.contentH / vh * 100)}% of the viewport tall while a sibling fits in ${Math.round(shortest.contentH / vh * 100)}% — the fold falls deep inside the section`, + }); + } + return findings; +} + export { checkBorders, isEmojiOnlyText, @@ -4769,4 +5116,8 @@ export { measureHiddenTextDOM, checkContentHiddenAtRest, checkEdgeFlushCardsDOM, + isOpaqueDecoratedBox, + isLayeredElement, + checkTextOcclusionDOM, + checkFirstViewportColumnOverflowDOM, }; diff --git a/site/pages/index.astro b/site/pages/index.astro index ecb3fbd44..2f07e1452 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -523,7 +523,7 @@ import '../styles/testimonials.css';
06

Block slop before it ships.

-

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

+

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

@@ -801,7 +801,7 @@ import '../styles/testimonials.css';
  • CLI for CI - npx impeccable detect src/ in a PR check. 57 deterministic rules. JSON output, exit codes for build gates. + npx impeccable detect src/ in a PR check. 59 deterministic rules. JSON output, exit codes for build gates. View on npm →
  • diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 4ffeadec6..be21cdafa 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -359,6 +359,29 @@ describe('detectUrl — browser-only fixtures', () => { } }); + it('text-occlusion: box-over-text, inline padding leak, headline/card overhang flag; leading bleed, scrim, fixed bar pass', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/text-occlusion.html`, { visualContrast: false }); + const hits = f.filter(r => r.antipattern === 'text-occlusion'); + const snippets = hits.map(h => h.snippet).join('\n'); + assert.match(snippets, /flag-box-text/, `opaque box painted over text should flag: ${snippets}`); + assert.match(snippets, /flag-leak/, `inline element with leaked opaque padding should flag: ${snippets}`); + assert.match(snippets, /flag-headline/, `headline overhanging an opaque card should flag: ${snippets}`); + for (const cls of ['pass-title', 'pass-eyebrow', 'pass-hero', 'cap', 'pass-under', 'pass-fixedbar']) { + assert.doesNotMatch(snippets, new RegExp(cls), `".${cls}" must not flag: ${snippets}`); + } + assert.equal(hits.length, 3, `expected exactly 3 text-occlusion findings, got ${hits.length}: ${snippets}`); + }); + + it('first-viewport-column-overflow: stretched-hero column flags; balanced columns and single hero pass', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/first-viewport-column-overflow.html`, { visualContrast: false }); + const hits = f.filter(r => r.antipattern === 'first-viewport-column-overflow'); + assert.equal(hits.length, 1, `expected exactly 1 first-viewport-column-overflow finding, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`); + assert.match(hits[0].snippet, /\bflag\b/, `finding must attach to the stretched-hero section: ${hits[0].snippet}`); + for (const cls of ['pass-balanced', 'pass-hero']) { + assert.doesNotMatch(hits[0].snippet, new RegExp(cls), `".${cls}" must not flag`); + } + }); + it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => { const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load', diff --git a/tests/fixtures/antipatterns/first-viewport-column-overflow.html b/tests/fixtures/antipatterns/first-viewport-column-overflow.html new file mode 100644 index 000000000..e2f8cb53e --- /dev/null +++ b/tests/fixtures/antipatterns/first-viewport-column-overflow.html @@ -0,0 +1,55 @@ + + + + +first-viewport-column-overflow fixture + + + +
    +
    +

    Upload your film and add a narrator

    +

    Paste a link or drop a file. We transcribe it, translate, and one narrator reads over the top.

    +

    The original stays audible underneath, just quieter.

    +
    + +
    + +
    +
    +

    Left column with a normal amount of copy that fits comfortably within the opening viewport.

    +

    Two short paragraphs, nothing that runs long.

    +
    +
    +

    Right column, also short. Both sides sit inside the first screen.

    +

    No column stretches the fold.

    +
    +
    + +
    +
    +
    + + diff --git a/tests/fixtures/antipatterns/text-occlusion.html b/tests/fixtures/antipatterns/text-occlusion.html new file mode 100644 index 000000000..15e4d1b8f --- /dev/null +++ b/tests/fixtures/antipatterns/text-occlusion.html @@ -0,0 +1,81 @@ + + + + +text-occlusion fixture + + + +
    +
    +

    Root cause identified in four minutes

    + +
    + +
    + +
    Incident closed. Missing index on the replica, found from one alert.
    +
    + +
    +
    The trace you need is the one they sampled away.
    +
    +

    INCIDENT 4417

    +

    service checkout-api

    +
    +
    + +
    +

    Family Italian on the waterfront

    +

    Trattoria da Nonna Lucia

    +
    + +
    + +
    +
    Villa on the cliff
    +
    + +

    Every span of every trace, kept on disk, no sampling ever applied here.

    +
    +
    8/8 services up · p99 1.20s · retention infinite
    + +