detector: body-text-viewport-edge rule + OKLCH/var-resolution + anchor-inherit FP fixes

New rule: body-text-viewport-edge flags body paragraphs that render flush
against the left/right viewport edges (no container padding). Tested via
the new tests/fixtures/antipatterns/body-text-viewport-edge.html fixture
(3 flag cases, 5 pass cases) and the test in detect-antipatterns-browser.

False-positive class fixes — all jsdom-mode only (real browsers resolve
the cascade correctly so these gates stay inert there). Five related
gaps that compounded into ~14× spurious contrast findings on Tailwind v4
pages with OKLCH color tokens:

  • OKLCH parser. jsdom returns the literal "oklch(...)" string from
    getComputedStyle; the detector now converts to sRGB via Björn
    Ottosson's matrices. Handles Tailwind v4's compact minified form
    "oklch(21.5%.02 50)" (no space after %).
  • var() resolution. resolveBackground + checkElementColors now
    accept the existing customPropMap and parse `var(--color-paper)`
    etc. as proper RGB via the new parseColorResolved helper.
  • bg-color before bg-image. The old order bailed on any gradient
    ancestor before checking for a solid background-color underneath,
    causing the body's decorative paper-grain gradient to be measured
    against instead of the page's actual `bg-paper` cream.
  • body/html-level gradient → white fallback. When the only opaque
    ancestor we can read is body/html with a gradient overlay (and
    jsdom can't decompose `background: var(--paper) gradient` to
    extract the solid color), return white instead of falling through
    to resolveGradientStops — which was picking up paper-grain noise
    colors and using them as the bg.
  • Anchor-inherit workaround for jsdom :link UA specificity.
    Tailwind v4's preflight declares `a { color: inherit }` (0,0,1).
    jsdom's UA stylesheet has `:link { color: blue }` at (0,1,1) and
    wins the cascade. Real Chrome wraps :link in :where() (0,0,0) so
    the page rule wins. When the page declares the inherit rule AND
    we see jsdom's default `rgb(0,0,238)` on an anchor, walk to the
    nearest non-anchor ancestor and use its color.
  • Alpha-fallback safety gate. When text has alpha<1 AND we couldn't
    find an opaque ancestor (effectiveBg null), skip the contrast
    finding. Covers any remaining FP class the deeper fixes miss.

Verified end-to-end against an Opus iter-1 artifact on Tailwind v4 with
14 cream/cream FPs + 2 blue-link UA FPs before; 0 findings after, while
the color.html fixture's 12 real low-contrast cases continue to flag
(verified via direct detectHtml calls).

cli/engine/detect-antipatterns-browser.js is the generated browser
distribution — regenerated from .mjs via scripts/build-browser-detector.js
(no manual edits to the generated file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-05-12 13:14:47 -07:00
co-authored by Claude Opus 4.7
parent e3ad2eff25
commit b9bf496e35
4 changed files with 900 additions and 89 deletions
+357 -43
View File
@@ -319,6 +319,13 @@ const ANTIPATTERNS = [
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
},
{
id: 'tight-leading',
category: 'quality',
@@ -563,7 +570,20 @@ function checkColors(opts) {
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
const threshold = isLargeText ? 3.0 : 4.5;
if (ratio < threshold) {
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
// Skip the false-positive class where text has alpha < 1 AND we
// couldn't find an opaque ancestor (effectiveBg is null, we're
// comparing against gradient-stop fallback). In jsdom mode the
// detector can't resolve `var(--X)` color tokens, so a dark
// section sitting between the text and the body's decorative
// gradient is invisible to us — we end up measuring contrast
// against the body's paper-grain noise instead of the real
// local bg. Real low-contrast bugs use alpha=1 and have a
// resolvable opaque ancestor; semi-transparent Tailwind tokens
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
const isAlphaFallbackFP = !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])}` });
}
}
}
@@ -708,38 +728,102 @@ function checkItalicSerif(opts) {
}];
}
// Color saturation check. Returns true when the color has visible
// chroma — i.e., it's an "accent color" rather than near-neutral.
// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
// expected to be pre-resolved by the caller.
function isAccentColor(cssColor) {
if (!cssColor) return false;
const s = String(cssColor).trim();
// rgb / rgba — direct channel-distance check.
const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));
const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
if (rgbStrict) {
const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
}
// #hex — 3, 4, 6, or 8 digit.
const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
if (hexM) {
let h = hexM[1];
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
else h = h.slice(0, 6);
if (h.length === 6) {
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
}
}
// oklch(L C H) — chroma C is what matters. Typical neutral grays
// have C < 0.02; visible accents are 0.05+. CSS minification can
// collapse spaces between L% and C ("oklch(43%.15 34)"), so we
// extract all numbers and take the second rather than matching a
// strict L-then-whitespace-then-C pattern.
if (/^oklch\(/i.test(s)) {
const nums = s.match(/\d*\.\d+|\d+/g);
if (nums && nums.length >= 2) {
const c = parseFloat(nums[1]);
return !Number.isNaN(c) && c >= 0.05;
}
}
// hsl(H, S%, L%) — saturation > 20% reads as accent.
const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
if (hslM) {
const sat = parseFloat(hslM[1]);
return !Number.isNaN(sat) && sat >= 20;
}
return false;
}
// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
// previousElementSibling, and gate on uppercase + tracked + small.
// previousElementSibling, and gate on EITHER the classic tracked-
// uppercase eyebrow OR the modern accent-colored bold eyebrow.
function checkHeroEyebrow(opts) {
const {
headingTag, headingText, headingFontSize,
siblingTag, siblingText, siblingTextTransform,
siblingFontSize, siblingLetterSpacing,
siblingFontWeight, siblingColor,
} = opts;
if (headingTag !== 'h1') return [];
if (!headingFontSize || headingFontSize < 48) return [];
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
// But modern hero h1s use clamp() / vw / var(--text-*), none of which
// jsdom can resolve — the computed value comes back as "2em" or
// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
// on virtually every Tailwind v4 / framework build. The other gates
// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
// tracked-caps) are tight enough to avoid false positives on non-
// hero h1s — a tiny tan label directly above any h1 is the
// antipattern regardless of how big the h1 ends up.
if (!siblingTag) return [];
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
// headings) — never an eyebrow.
if (HEADING_TAGS.has(siblingTag)) return [];
const text = (siblingText || '').trim();
if (text.length < 2 || text.length > 30) return [];
if (text.length < 2 || text.length > 60) return [];
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
// Uppercase: either via text-transform, or the content is already typed
// uppercase (no lowercase letters, at least one uppercase letter).
// Branch A: classic tracked-uppercase eyebrow.
const isUppercased = siblingTextTransform === 'uppercase'
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
if (!isUppercased) return [];
const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
if (!(siblingLetterSpacing >= 1.6)) return [];
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
// Branch B: modern accent-bold eyebrow — sentence case, low
// tracking, but bold + accent-colored. The style choices changed;
// the pattern is the same kicker-above-headline anti-pattern.
const weight = Number(siblingFontWeight) || 400;
const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
if (!isClassicTracked && !isAccentBold) return [];
const headingTextSnippet = (headingText || '').trim().slice(0, 60);
const eyebrowSnippet = text.slice(0, 40);
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
return [{
id: 'hero-eyebrow-chip',
snippet: `eyebrow chip "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
}];
}
@@ -993,43 +1077,60 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
function resolveBackground(el, win) {
function resolveBackground(el, win, customPropMap) {
let current = el;
while (current && current.nodeType === 1) {
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
// If this element has a background-image (gradient or url), it's visually
// opaque but we can't determine the effective color — bail out so callers
// don't get a false solid-color answer.
const bgImage = style.backgroundImage || '';
if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
return null;
}
const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
// Try the solid bg-color FIRST. If the element has both a solid color
// and a gradient/url overlay (a common pattern: `background: var(--paper)
// radial-gradient(...)` for paper-grain texture), the solid color is the
// dominant visible surface for contrast purposes; the overlay is
// decorative. The old behavior bailed on any gradient ancestor, which
// caused massive false-positive contrast findings on grain-textured
// body backgrounds.
let bg = parseRgb(style.backgroundColor);
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom doesn't decompose background shorthand — parse raw style attr
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
// Check for gradient or url() image in inline style too
if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
if (hexMatch) {
const h = hexMatch[1];
if (h.length === 6) {
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
} else {
bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
}
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
if (bg && bg.a > 0.1) {
if (IS_BROWSER || bg.a >= 0.5) return bg;
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return { r: 255, g: 255, b: 255, a: 1 };
}
return null;
}
current = current.parentElement;
}
return { r: 255, g: 255, b: 255 };
@@ -1247,9 +1348,144 @@ function checkElementHeroEyebrowDOM(el) {
siblingTextTransform: sibStyle.textTransform || '',
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
siblingFontWeight: sibStyle.fontWeight || '',
siblingColor: sibStyle.color || '',
});
}
// Build a map of CSS custom properties declared on :root / :host / html.
// Used to resolve var(--X) refs that jsdom returns verbatim in
// getComputedStyle. Tailwind v4 routes every utility class through
// CSS vars (font-weight: var(--font-weight-bold), font-size:
// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
// resolution every style-based check silently fails on Tailwind v4
// builds — the values come back as literal "var(--font-weight-bold)"
// strings and parseFloat returns NaN.
function buildCustomPropMap(document) {
const map = new Map();
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return map; }
for (const sheet of sheets) {
let rules;
try { rules = Array.from(sheet.cssRules || []); }
catch { continue; }
for (const rule of rules) {
// Style rules only (type 1). Walk @media / @supports if present.
if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
continue;
}
if (rule.type !== 1 /* STYLE_RULE */) continue;
const sel = rule.selectorText || '';
if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
const style = rule.style;
if (!style) continue;
for (let i = 0; i < style.length; i++) {
const prop = style[i];
if (!prop || !prop.startsWith('--')) continue;
const val = style.getPropertyValue(prop).trim();
if (val) map.set(prop, val);
}
}
}
return map;
}
// Resolve var(--X[, fallback]) refs in a computed-style value string.
// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
// the original string when no refs are present or the chain doesn't
// resolve. Safe to call on already-resolved values.
function resolveVarRefs(raw, customPropMap, depth = 0) {
if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
if (depth > 8) return raw;
return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
const v = customPropMap.get(name);
if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
const a = C * Math.cos(hRad);
const b = C * Math.sin(hRad);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
// Use this when the input might be any CSS color form; use plain parseRgb
// when you only expect computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
}
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
function parseColorResolved(str, customPropMap) {
if (!str) return null;
const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
return parseAnyColor(resolved);
}
const REPEATED_KICKER_SKIP_SELECTOR = [
'nav',
'form',
@@ -1501,7 +1737,7 @@ function resolveLengthPx(value, fontSizePx) {
// Both adapters resolve font-size, line-height and letter-spacing to pixels
// before calling this so the pure function only deals with numbers.
function checkQuality(opts) {
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0 } = opts;
const findings = [];
// Skip browser extension injected elements
const elId = el.id || '';
@@ -1552,6 +1788,40 @@ function checkQuality(opts) {
}
}
// --- Body text touching viewport edge --- (browser-only: needs rect)
// Catches the failure mode where the agent ships body paragraphs
// with NO container providing horizontal padding — text bleeds
// directly to the viewport edge. Different from cramped-padding,
// which requires a colored/bordered container. Here the failure
// is the absence of the container entirely.
//
// Gate aggressively to avoid false positives:
// - <p> or <li> only (body content; not headings, not nav, not
// wrappers)
// - text > 40 chars (paragraph-like, not a label)
// - rect.width > 50% of viewport (real body, not a pull-quote)
// - rect.left < 16 OR rect.right > viewport - 16 (actually
// touching the edge)
// - not inside <nav> or <header> (those legitimately bleed)
// - element itself has no background-color (intentional full-bleed
// sections set a bg-color and provide their own internal padding)
if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
const widthRatio = rect.width / viewportWidth;
const leftClose = rect.left < 16;
const rightClose = rect.right > viewportWidth - 16;
if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
const which = leftClose && rightClose
? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
: leftClose
? `left ${Math.round(rect.left)}px`
: `right ${Math.round(viewportWidth - rect.right)}px`;
findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
}
}
// --- Tight line height ---
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
if (lineHeightPx != null && fontSize > 0) {
@@ -1613,7 +1883,8 @@ function checkElementQualityDOM(el) {
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
const rect = el.getBoundingClientRect();
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth });
}
// Pure page-level skipped-heading walk. Takes a Document so it works in both
@@ -1688,14 +1959,47 @@ function checkElementBorders(tag, style, overrides, resolvedRadius) {
return checkBorders(tag, widths, colors, radius);
}
function checkElementColors(el, style, tag, window) {
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window);
const effectiveBg = resolveBackground(el, window, customPropMap);
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
if (!textColor) textColor = parseRgb(style.color);
// Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
// blue }` at high specificity. The page's `a { color: inherit }` rule
// (Tailwind v4 preflight) loses to jsdom even though it WINS in real
// browsers (Chrome's UA wraps :link in :where() — zero specificity).
// When the page declares the inherit rule AND we see jsdom's default
// link blue on an anchor, walk to the nearest non-anchor ancestor and
// use its color instead.
if (
hasAnchorInheritRule &&
textColor &&
textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
(tag === 'a' || el.closest?.('a'))
) {
let cur = el.parentElement;
while (cur && cur.tagName !== 'HTML') {
if (cur.tagName !== 'A') {
const ps = window.getComputedStyle(cur);
const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
textColor = inh;
break;
}
}
cur = cur.parentElement;
}
}
return checkColors({
tag,
textColor: parseRgb(style.color),
textColor,
bgColor: readOwnBackgroundColor(el, style),
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
@@ -1757,24 +2061,34 @@ function checkElementItalicSerif(el, style, tag) {
});
}
function checkElementHeroEyebrow(el, style, tag, window) {
function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
if (tag !== 'h1') return [];
const sibling = el.previousElementSibling;
if (!sibling) return [];
const sibStyle = window.getComputedStyle(sibling);
const siblingFontSize = parseFloat(sibStyle.fontSize) || 0;
// Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
// etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
// without resolution every style-based gate fails silently on Tailwind v4 builds.
const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
const siblingFontSize = parseFloat(fontSizeRaw) || 0;
// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
// gate falls through cleanly. jsdom returns letter-spacing verbatim
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
return checkHeroEyebrow({
headingTag: tag,
headingText: el.textContent || '',
headingFontSize: parseFloat(style.fontSize) || 0,
headingFontSize: parseFloat(headingFontSizeRaw) || 0,
siblingTag: sibling.tagName.toLowerCase(),
siblingText: sibling.textContent || '',
siblingTextTransform: sibStyle.textTransform || '',
siblingFontSize,
siblingLetterSpacing: resolveLengthPx(sibStyle.letterSpacing, siblingFontSize) || 0,
siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
siblingFontWeight: fontWeightRaw || '',
siblingColor: colorRaw || '',
});
}
+445 -46
View File
@@ -315,6 +315,13 @@ const ANTIPATTERNS = [
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
},
{
id: 'tight-leading',
category: 'quality',
@@ -559,7 +566,20 @@ function checkColors(opts) {
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
const threshold = isLargeText ? 3.0 : 4.5;
if (ratio < threshold) {
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
// Skip the false-positive class where text has alpha < 1 AND we
// couldn't find an opaque ancestor (effectiveBg is null, we're
// comparing against gradient-stop fallback). In jsdom mode the
// detector can't resolve `var(--X)` color tokens, so a dark
// section sitting between the text and the body's decorative
// gradient is invisible to us — we end up measuring contrast
// against the body's paper-grain noise instead of the real
// local bg. Real low-contrast bugs use alpha=1 and have a
// resolvable opaque ancestor; semi-transparent Tailwind tokens
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
const isAlphaFallbackFP = !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])}` });
}
}
}
@@ -704,38 +724,102 @@ function checkItalicSerif(opts) {
}];
}
// Color saturation check. Returns true when the color has visible
// chroma — i.e., it's an "accent color" rather than near-neutral.
// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
// expected to be pre-resolved by the caller.
function isAccentColor(cssColor) {
if (!cssColor) return false;
const s = String(cssColor).trim();
// rgb / rgba — direct channel-distance check.
const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));
const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
if (rgbStrict) {
const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
}
// #hex — 3, 4, 6, or 8 digit.
const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
if (hexM) {
let h = hexM[1];
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
else h = h.slice(0, 6);
if (h.length === 6) {
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
}
}
// oklch(L C H) — chroma C is what matters. Typical neutral grays
// have C < 0.02; visible accents are 0.05+. CSS minification can
// collapse spaces between L% and C ("oklch(43%.15 34)"), so we
// extract all numbers and take the second rather than matching a
// strict L-then-whitespace-then-C pattern.
if (/^oklch\(/i.test(s)) {
const nums = s.match(/\d*\.\d+|\d+/g);
if (nums && nums.length >= 2) {
const c = parseFloat(nums[1]);
return !Number.isNaN(c) && c >= 0.05;
}
}
// hsl(H, S%, L%) — saturation > 20% reads as accent.
const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
if (hslM) {
const sat = parseFloat(hslM[1]);
return !Number.isNaN(sat) && sat >= 20;
}
return false;
}
// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
// previousElementSibling, and gate on uppercase + tracked + small.
// previousElementSibling, and gate on EITHER the classic tracked-
// uppercase eyebrow OR the modern accent-colored bold eyebrow.
function checkHeroEyebrow(opts) {
const {
headingTag, headingText, headingFontSize,
siblingTag, siblingText, siblingTextTransform,
siblingFontSize, siblingLetterSpacing,
siblingFontWeight, siblingColor,
} = opts;
if (headingTag !== 'h1') return [];
if (!headingFontSize || headingFontSize < 48) return [];
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
// But modern hero h1s use clamp() / vw / var(--text-*), none of which
// jsdom can resolve — the computed value comes back as "2em" or
// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
// on virtually every Tailwind v4 / framework build. The other gates
// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
// tracked-caps) are tight enough to avoid false positives on non-
// hero h1s — a tiny tan label directly above any h1 is the
// antipattern regardless of how big the h1 ends up.
if (!siblingTag) return [];
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
// headings) — never an eyebrow.
if (HEADING_TAGS.has(siblingTag)) return [];
const text = (siblingText || '').trim();
if (text.length < 2 || text.length > 30) return [];
if (text.length < 2 || text.length > 60) return [];
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
// Uppercase: either via text-transform, or the content is already typed
// uppercase (no lowercase letters, at least one uppercase letter).
// Branch A: classic tracked-uppercase eyebrow.
const isUppercased = siblingTextTransform === 'uppercase'
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
if (!isUppercased) return [];
const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
if (!(siblingLetterSpacing >= 1.6)) return [];
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
// Branch B: modern accent-bold eyebrow — sentence case, low
// tracking, but bold + accent-colored. The style choices changed;
// the pattern is the same kicker-above-headline anti-pattern.
const weight = Number(siblingFontWeight) || 400;
const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
if (!isClassicTracked && !isAccentBold) return [];
const headingTextSnippet = (headingText || '').trim().slice(0, 60);
const eyebrowSnippet = text.slice(0, 40);
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
return [{
id: 'hero-eyebrow-chip',
snippet: `eyebrow chip "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
}];
}
@@ -989,43 +1073,60 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
function resolveBackground(el, win) {
function resolveBackground(el, win, customPropMap) {
let current = el;
while (current && current.nodeType === 1) {
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
// If this element has a background-image (gradient or url), it's visually
// opaque but we can't determine the effective color — bail out so callers
// don't get a false solid-color answer.
const bgImage = style.backgroundImage || '';
if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
return null;
}
const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
// Try the solid bg-color FIRST. If the element has both a solid color
// and a gradient/url overlay (a common pattern: `background: var(--paper)
// radial-gradient(...)` for paper-grain texture), the solid color is the
// dominant visible surface for contrast purposes; the overlay is
// decorative. The old behavior bailed on any gradient ancestor, which
// caused massive false-positive contrast findings on grain-textured
// body backgrounds.
let bg = parseRgb(style.backgroundColor);
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom doesn't decompose background shorthand — parse raw style attr
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
// Check for gradient or url() image in inline style too
if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
if (hexMatch) {
const h = hexMatch[1];
if (h.length === 6) {
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
} else {
bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
}
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
if (bg && bg.a > 0.1) {
if (IS_BROWSER || bg.a >= 0.5) return bg;
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return { r: 255, g: 255, b: 255, a: 1 };
}
return null;
}
current = current.parentElement;
}
return { r: 255, g: 255, b: 255 };
@@ -1243,9 +1344,144 @@ function checkElementHeroEyebrowDOM(el) {
siblingTextTransform: sibStyle.textTransform || '',
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
siblingFontWeight: sibStyle.fontWeight || '',
siblingColor: sibStyle.color || '',
});
}
// Build a map of CSS custom properties declared on :root / :host / html.
// Used to resolve var(--X) refs that jsdom returns verbatim in
// getComputedStyle. Tailwind v4 routes every utility class through
// CSS vars (font-weight: var(--font-weight-bold), font-size:
// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
// resolution every style-based check silently fails on Tailwind v4
// builds — the values come back as literal "var(--font-weight-bold)"
// strings and parseFloat returns NaN.
function buildCustomPropMap(document) {
const map = new Map();
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return map; }
for (const sheet of sheets) {
let rules;
try { rules = Array.from(sheet.cssRules || []); }
catch { continue; }
for (const rule of rules) {
// Style rules only (type 1). Walk @media / @supports if present.
if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
continue;
}
if (rule.type !== 1 /* STYLE_RULE */) continue;
const sel = rule.selectorText || '';
if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
const style = rule.style;
if (!style) continue;
for (let i = 0; i < style.length; i++) {
const prop = style[i];
if (!prop || !prop.startsWith('--')) continue;
const val = style.getPropertyValue(prop).trim();
if (val) map.set(prop, val);
}
}
}
return map;
}
// Resolve var(--X[, fallback]) refs in a computed-style value string.
// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
// the original string when no refs are present or the chain doesn't
// resolve. Safe to call on already-resolved values.
function resolveVarRefs(raw, customPropMap, depth = 0) {
if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
if (depth > 8) return raw;
return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
const v = customPropMap.get(name);
if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
const a = C * Math.cos(hRad);
const b = C * Math.sin(hRad);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
// Use this when the input might be any CSS color form; use plain parseRgb
// when you only expect computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
}
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
function parseColorResolved(str, customPropMap) {
if (!str) return null;
const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
return parseAnyColor(resolved);
}
const REPEATED_KICKER_SKIP_SELECTOR = [
'nav',
'form',
@@ -1497,7 +1733,7 @@ function resolveLengthPx(value, fontSizePx) {
// Both adapters resolve font-size, line-height and letter-spacing to pixels
// before calling this so the pure function only deals with numbers.
function checkQuality(opts) {
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0 } = opts;
const findings = [];
// Skip browser extension injected elements
const elId = el.id || '';
@@ -1548,6 +1784,40 @@ function checkQuality(opts) {
}
}
// --- Body text touching viewport edge --- (browser-only: needs rect)
// Catches the failure mode where the agent ships body paragraphs
// with NO container providing horizontal padding — text bleeds
// directly to the viewport edge. Different from cramped-padding,
// which requires a colored/bordered container. Here the failure
// is the absence of the container entirely.
//
// Gate aggressively to avoid false positives:
// - <p> or <li> only (body content; not headings, not nav, not
// wrappers)
// - text > 40 chars (paragraph-like, not a label)
// - rect.width > 50% of viewport (real body, not a pull-quote)
// - rect.left < 16 OR rect.right > viewport - 16 (actually
// touching the edge)
// - not inside <nav> or <header> (those legitimately bleed)
// - element itself has no background-color (intentional full-bleed
// sections set a bg-color and provide their own internal padding)
if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
const widthRatio = rect.width / viewportWidth;
const leftClose = rect.left < 16;
const rightClose = rect.right > viewportWidth - 16;
if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
const which = leftClose && rightClose
? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
: leftClose
? `left ${Math.round(rect.left)}px`
: `right ${Math.round(viewportWidth - rect.right)}px`;
findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
}
}
// --- Tight line height ---
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
if (lineHeightPx != null && fontSize > 0) {
@@ -1609,7 +1879,8 @@ function checkElementQualityDOM(el) {
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
const rect = el.getBoundingClientRect();
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth });
}
// Pure page-level skipped-heading walk. Takes a Document so it works in both
@@ -1684,14 +1955,47 @@ function checkElementBorders(tag, style, overrides, resolvedRadius) {
return checkBorders(tag, widths, colors, radius);
}
function checkElementColors(el, style, tag, window) {
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window);
const effectiveBg = resolveBackground(el, window, customPropMap);
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
if (!textColor) textColor = parseRgb(style.color);
// Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
// blue }` at high specificity. The page's `a { color: inherit }` rule
// (Tailwind v4 preflight) loses to jsdom even though it WINS in real
// browsers (Chrome's UA wraps :link in :where() — zero specificity).
// When the page declares the inherit rule AND we see jsdom's default
// link blue on an anchor, walk to the nearest non-anchor ancestor and
// use its color instead.
if (
hasAnchorInheritRule &&
textColor &&
textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
(tag === 'a' || el.closest?.('a'))
) {
let cur = el.parentElement;
while (cur && cur.tagName !== 'HTML') {
if (cur.tagName !== 'A') {
const ps = window.getComputedStyle(cur);
const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
textColor = inh;
break;
}
}
cur = cur.parentElement;
}
}
return checkColors({
tag,
textColor: parseRgb(style.color),
textColor,
bgColor: readOwnBackgroundColor(el, style),
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
@@ -1753,24 +2057,34 @@ function checkElementItalicSerif(el, style, tag) {
});
}
function checkElementHeroEyebrow(el, style, tag, window) {
function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
if (tag !== 'h1') return [];
const sibling = el.previousElementSibling;
if (!sibling) return [];
const sibStyle = window.getComputedStyle(sibling);
const siblingFontSize = parseFloat(sibStyle.fontSize) || 0;
// Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
// etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
// without resolution every style-based gate fails silently on Tailwind v4 builds.
const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
const siblingFontSize = parseFloat(fontSizeRaw) || 0;
// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
// gate falls through cleanly. jsdom returns letter-spacing verbatim
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
return checkHeroEyebrow({
headingTag: tag,
headingText: el.textContent || '',
headingFontSize: parseFloat(style.fontSize) || 0,
headingFontSize: parseFloat(headingFontSizeRaw) || 0,
siblingTag: sibling.tagName.toLowerCase(),
siblingText: sibling.textContent || '',
siblingTextTransform: sibStyle.textTransform || '',
siblingFontSize,
siblingLetterSpacing: resolveLengthPx(sibStyle.letterSpacing, siblingFontSize) || 0,
siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
siblingFontWeight: fontWeightRaw || '',
siblingColor: colorRaw || '',
});
}
@@ -3038,6 +3352,48 @@ function buildBorderOverrideMap(document, window) {
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// jsdom detection (default for HTML files)
// ---------------------------------------------------------------------------
@@ -3074,6 +3430,16 @@ async function detectHtml(filePath) {
}
}
// jsdom does not implement CSS `@layer` rules — every utility class
// inside `@layer utilities { ... }` is silently ignored, so computed
// styles come back empty. Tailwind v4 wraps every utility class in
// an @layer, which means jsdom returns empty strings for fontSize /
// fontWeight / textTransform / letterSpacing on every Tailwind-styled
// element. Strip the @layer wrapper, keep the inner rules as flat
// CSS that jsdom can process. The cascade ordering @layer provides
// doesn't matter for our checks — we only read computed values.
processedHtml = unwrapCssAtLayer(processedHtml);
const dom = new JSDOM(processedHtml, {
url: `file://${resolvedPath}`,
});
@@ -3087,6 +3453,39 @@ async function detectHtml(filePath) {
// by the border check adapter as a fallback.
const borderOverrides = buildBorderOverrideMap(document, window);
// Pre-pass: collect :root / :host / html CSS custom properties so the
// checks can resolve var(--X) refs that jsdom returns verbatim from
// getComputedStyle. Tailwind v4 wraps every utility-class value in a
// CSS var; without this, font-weight / font-size / letter-spacing /
// color all come back as literal "var(--X)" strings.
const customPropMap = buildCustomPropMap(document);
// Pre-pass: detect whether the page's CSS declares `a { color: inherit }`
// (Tailwind v4 preflight signature). When present, real browsers render
// anchors using the cascaded ancestor color, but jsdom's UA stylesheet
// applies `:link { color: blue }` at higher specificity, so anchors come
// back as `rgb(0, 0, 238)` regardless. checkElementColors uses this
// flag to walk for the cascaded color when it sees jsdom's blue default
// on an anchor — preventing a whole class of contrast false positives
// on Tailwind v4 pages.
let hasAnchorInheritRule = false;
const scanForAnchorInherit = (rules) => {
for (const rule of rules) {
if (rule.selectorText === 'a' && rule.style && rule.style.color === 'inherit') return true;
// Recurse into @layer / @media / @supports / etc.
if (rule.cssRules && scanForAnchorInherit(rule.cssRules)) return true;
}
return false;
};
for (const sheet of document.styleSheets) {
try {
if (scanForAnchorInherit(sheet.cssRules || [])) {
hasAnchorInheritRule = true;
break;
}
} catch (e) { /* cross-origin sheet, skip */ }
}
// Element-level checks (borders + colors + motion)
for (const el of document.querySelectorAll('*')) {
const tag = el.tagName.toLowerCase();
@@ -3095,10 +3494,10 @@ async function detectHtml(filePath) {
for (const f of checkElementBorders(tag, style, borderOverrides.get(el), resolvedRadius)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementColors(el, style, tag, window)) {
for (const f of checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window))) {
for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementMotion(tag, style)) {
@@ -3110,7 +3509,7 @@ async function detectHtml(filePath) {
for (const f of checkElementItalicSerif(el, style, tag)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementHeroEyebrow(el, style, tag, window)) {
for (const f of checkElementHeroEyebrow(el, style, tag, window, customPropMap)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementQuality(el, style, tag, window)) {
@@ -97,4 +97,14 @@ describe('detectUrl — browser-only fixtures', () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
});
it('body-text-viewport-edge: 3 flag paragraphs/list-items, 0 pass cases', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/body-text-viewport-edge.html`);
const edges = f.filter(r => r.antipattern === 'body-text-viewport-edge');
// Fixture has 3 escape-styled <p>/<li> paragraphs that bleed to
// the viewport edges. The pass column has 5 paragraphs that
// should not fire (centered container, inside nav, inside header,
// inside section with own background, short label < 40 chars).
assert.equal(edges.length, 3, `expected 3 body-text-viewport-edge findings, got ${edges.length}: ${JSON.stringify(edges.map(e => e.snippet))}`);
});
});
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Body text viewport edge — Should Flag vs Should Pass</title>
<style>
/* Fixture for the body-text-viewport-edge rule. The rule fires
when a <p> or <li> with substantial text content has rect.left
within 16px of the viewport edge (or rect.right within 16px
of viewport.width) AND the element has no own background-color
AND is not inside <nav>/<header> AND is in normal flow. */
body { font: 14px/1.5 system-ui, sans-serif; margin: 0; color: #111; background: #fff; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; padding: 24px; max-width: 1400px; margin: 0 auto; }
.col h2 { font: 600 14px/1 system-ui; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.case-label { display: block; font-size: 12px; color: #64748b; margin: 16px 0 4px; font-style: italic; }
.container-good { max-width: 720px; margin: 0 auto; padding: 16px 32px; }
.container-tight { padding: 16px 32px; }
.full-bleed-section { background: #fef3c7; padding: 24px 16px; }
nav.site-nav { padding: 12px 0; background: #f5f5f5; }
header.banner { padding: 12px 0; background: #e5e7eb; }
/* Override the grid container's own padding so we can demonstrate
a paragraph that genuinely bleeds to the viewport edge inside
the flag column — the col itself shouldn't reset its padding. */
.flag-fullwidth-p { /* no container; placed directly in body via .escape */ }
.escape { position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; width: 100vw; padding: 0; }
</style>
</head>
<body>
<!-- ════════════════════════════════════════════════════════════
FLAG CASES — body text bleeds to viewport edge.
These paragraphs are placed OUTSIDE the .grid container so
their bounding rect actually touches the viewport edges.
═══════════════════════════════════════════════════════════ -->
<div class="escape">
<p>Lucia opened this place in 1978. She cooked every night for fifty years, making the same dishes: spaghetti carbonara, eggplant parmigiana, veal saltimbocca, fresh lasagna. She did not change a single recipe in all that time. Her grandson Anthony runs the kitchen now, using the same recipes with the same care.</p>
</div>
<div class="escape">
<p>This second flush-to-edge paragraph confirms the rule fires on every body paragraph that touches the viewport, not just one. The rendered width should be the full viewport, with text starting essentially at x=0.</p>
</div>
<div class="escape">
<ul>
<li>This is a list item whose text content is substantially long enough to qualify as body content. It also runs flush against the viewport edge with no left padding.</li>
</ul>
</div>
<!-- ════════════════════════════════════════════════════════════
PASS CASES — body text with adequate container padding.
═══════════════════════════════════════════════════════════ -->
<div class="grid">
<div class="col" data-col="pass">
<h2>Should pass</h2>
<span class="case-label">paragraph in centered container with 32px padding</span>
<div class="container-good">
<p>This paragraph sits inside a max-width container that's centered and has 32px horizontal padding. It does not touch the viewport edges and should not trigger the rule.</p>
</div>
<span class="case-label">paragraph inside &lt;nav&gt; (excluded — nav can bleed)</span>
<nav class="site-nav">
<p>This nav legitimately bleeds to the edges as a full-width header strip; the rule excludes elements inside &lt;nav&gt; or &lt;header&gt; so this does not flag.</p>
</nav>
<span class="case-label">paragraph inside &lt;header&gt; (excluded — header can bleed)</span>
<header class="banner">
<p>Banner headers commonly bleed to viewport edges with their own internal layout. The rule excludes &lt;header&gt; descendants.</p>
</header>
<span class="case-label">paragraph inside full-bleed section with own background</span>
<section class="full-bleed-section">
<p>This paragraph sits in a full-bleed section that has its own background-color set (intentional design move). The rule excludes paragraphs whose element has its own background-color.</p>
</section>
<span class="case-label">short label / button-ish text (excluded — &lt; 40 chars)</span>
<p class="container-tight">Short label.</p>
</div>
<div class="col" data-col="flag-notes">
<h2>Notes</h2>
<p>The flag cases above (rendered outside this grid) demonstrate the failure. The pass cases here show acceptable patterns. The rule gates aggressively to avoid false positives: it only flags &lt;p&gt; and &lt;li&gt; with &gt;40 chars whose width spans more than 50% of the viewport AND whose rect.left or rect.right is within 16px of a viewport edge AND which are not inside nav/header AND which have no own background-color AND which are in normal flow (not position:fixed/absolute).</p>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>