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