diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js
index 28935ada7..876895273 100644
--- a/cli/engine/detect-antipatterns-browser.js
+++ b/cli/engine/detect-antipatterns-browser.js
@@ -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:
+ // -
or
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