mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.7
parent
e3ad2eff25
commit
b9bf496e35
@@ -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 || '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <nav> (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 <nav> or <header> so this does not flag.</p>
|
||||
</nav>
|
||||
|
||||
<span class="case-label">paragraph inside <header> (excluded — header can bleed)</span>
|
||||
<header class="banner">
|
||||
<p>Banner headers commonly bleed to viewport edges with their own internal layout. The rule excludes <header> 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 — < 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 <p> and <li> with >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>
|
||||
Reference in New Issue
Block a user