mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +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:
|
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.',
|
'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',
|
id: 'tight-leading',
|
||||||
category: 'quality',
|
category: 'quality',
|
||||||
@@ -563,7 +570,20 @@ function checkColors(opts) {
|
|||||||
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
|
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
|
||||||
const threshold = isLargeText ? 3.0 : 4.5;
|
const threshold = isLargeText ? 3.0 : 4.5;
|
||||||
if (ratio < threshold) {
|
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
|
// 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) {
|
function checkHeroEyebrow(opts) {
|
||||||
const {
|
const {
|
||||||
headingTag, headingText, headingFontSize,
|
headingTag, headingText, headingFontSize,
|
||||||
siblingTag, siblingText, siblingTextTransform,
|
siblingTag, siblingText, siblingTextTransform,
|
||||||
siblingFontSize, siblingLetterSpacing,
|
siblingFontSize, siblingLetterSpacing,
|
||||||
|
siblingFontWeight, siblingColor,
|
||||||
} = opts;
|
} = opts;
|
||||||
if (headingTag !== 'h1') return [];
|
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 [];
|
if (!siblingTag) return [];
|
||||||
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
|
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
|
||||||
// headings) — never an eyebrow.
|
// headings) — never an eyebrow.
|
||||||
if (HEADING_TAGS.has(siblingTag)) return [];
|
if (HEADING_TAGS.has(siblingTag)) return [];
|
||||||
|
|
||||||
const text = (siblingText || '').trim();
|
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
|
// Branch A: classic tracked-uppercase eyebrow.
|
||||||
// uppercase (no lowercase letters, at least one uppercase letter).
|
|
||||||
const isUppercased = siblingTextTransform === 'uppercase'
|
const isUppercased = siblingTextTransform === 'uppercase'
|
||||||
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
|
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
|
||||||
if (!isUppercased) return [];
|
const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
|
||||||
|
|
||||||
if (!(siblingLetterSpacing >= 1.6)) return [];
|
// Branch B: modern accent-bold eyebrow — sentence case, low
|
||||||
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
|
// 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 headingTextSnippet = (headingText || '').trim().slice(0, 60);
|
||||||
const eyebrowSnippet = text.slice(0, 40);
|
const eyebrowSnippet = text.slice(0, 40);
|
||||||
|
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
|
||||||
return [{
|
return [{
|
||||||
id: 'hero-eyebrow-chip',
|
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;
|
return bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBackground(el, win) {
|
function resolveBackground(el, win, customPropMap) {
|
||||||
let current = el;
|
let current = el;
|
||||||
while (current && current.nodeType === 1) {
|
while (current && current.nodeType === 1) {
|
||||||
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
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 || '';
|
const bgImage = style.backgroundImage || '';
|
||||||
if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
|
const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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);
|
let bg = parseRgb(style.backgroundColor);
|
||||||
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
|
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
|
||||||
// jsdom doesn't decompose background shorthand — parse raw style attr
|
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
|
||||||
const rawStyle = current.getAttribute?.('style') || '';
|
// through customPropMap so Tailwind v4 color tokens become RGB.
|
||||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
if (customPropMap) {
|
||||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
||||||
// Check for gradient or url() image in inline style too
|
}
|
||||||
if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
|
if (!bg || bg.a < 0.1) {
|
||||||
bg = parseRgb(inlineBg);
|
// Inline-style fallback. jsdom doesn't decompose background
|
||||||
if (!bg && inlineBg) {
|
// shorthand, so colors set via inline style are otherwise invisible.
|
||||||
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
|
const rawStyle = current.getAttribute?.('style') || '';
|
||||||
if (hexMatch) {
|
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||||
const h = hexMatch[1];
|
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
||||||
if (h.length === 6) {
|
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
||||||
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
|
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
||||||
} 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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bg && bg.a > 0.1) {
|
if (bg && bg.a > 0.1) {
|
||||||
if (IS_BROWSER || bg.a >= 0.5) return bg;
|
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;
|
current = current.parentElement;
|
||||||
}
|
}
|
||||||
return { r: 255, g: 255, b: 255 };
|
return { r: 255, g: 255, b: 255 };
|
||||||
@@ -1247,9 +1348,144 @@ function checkElementHeroEyebrowDOM(el) {
|
|||||||
siblingTextTransform: sibStyle.textTransform || '',
|
siblingTextTransform: sibStyle.textTransform || '',
|
||||||
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
||||||
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 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 = [
|
const REPEATED_KICKER_SKIP_SELECTOR = [
|
||||||
'nav',
|
'nav',
|
||||||
'form',
|
'form',
|
||||||
@@ -1501,7 +1737,7 @@ function resolveLengthPx(value, fontSizePx) {
|
|||||||
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
||||||
// before calling this so the pure function only deals with numbers.
|
// before calling this so the pure function only deals with numbers.
|
||||||
function checkQuality(opts) {
|
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 = [];
|
const findings = [];
|
||||||
// Skip browser extension injected elements
|
// Skip browser extension injected elements
|
||||||
const elId = el.id || '';
|
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 ---
|
// --- Tight line height ---
|
||||||
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||||
if (lineHeightPx != null && fontSize > 0) {
|
if (lineHeightPx != null && fontSize > 0) {
|
||||||
@@ -1613,7 +1883,8 @@ function checkElementQualityDOM(el) {
|
|||||||
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
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
|
// 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);
|
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 directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||||
const hasDirectText = directText.trim().length > 0;
|
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({
|
return checkColors({
|
||||||
tag,
|
tag,
|
||||||
textColor: parseRgb(style.color),
|
textColor,
|
||||||
bgColor: readOwnBackgroundColor(el, style),
|
bgColor: readOwnBackgroundColor(el, style),
|
||||||
effectiveBg,
|
effectiveBg,
|
||||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
|
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 [];
|
if (tag !== 'h1') return [];
|
||||||
const sibling = el.previousElementSibling;
|
const sibling = el.previousElementSibling;
|
||||||
if (!sibling) return [];
|
if (!sibling) return [];
|
||||||
const sibStyle = window.getComputedStyle(sibling);
|
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
|
// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
|
||||||
// gate falls through cleanly. jsdom returns letter-spacing verbatim
|
// gate falls through cleanly. jsdom returns letter-spacing verbatim
|
||||||
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
|
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
|
||||||
return checkHeroEyebrow({
|
return checkHeroEyebrow({
|
||||||
headingTag: tag,
|
headingTag: tag,
|
||||||
headingText: el.textContent || '',
|
headingText: el.textContent || '',
|
||||||
headingFontSize: parseFloat(style.fontSize) || 0,
|
headingFontSize: parseFloat(headingFontSizeRaw) || 0,
|
||||||
siblingTag: sibling.tagName.toLowerCase(),
|
siblingTag: sibling.tagName.toLowerCase(),
|
||||||
siblingText: sibling.textContent || '',
|
siblingText: sibling.textContent || '',
|
||||||
siblingTextTransform: sibStyle.textTransform || '',
|
siblingTextTransform: sibStyle.textTransform || '',
|
||||||
siblingFontSize,
|
siblingFontSize,
|
||||||
siblingLetterSpacing: resolveLengthPx(sibStyle.letterSpacing, siblingFontSize) || 0,
|
siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
|
||||||
|
siblingFontWeight: fontWeightRaw || '',
|
||||||
|
siblingColor: colorRaw || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,13 @@ const ANTIPATTERNS = [
|
|||||||
description:
|
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.',
|
'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',
|
id: 'tight-leading',
|
||||||
category: 'quality',
|
category: 'quality',
|
||||||
@@ -559,7 +566,20 @@ function checkColors(opts) {
|
|||||||
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
|
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
|
||||||
const threshold = isLargeText ? 3.0 : 4.5;
|
const threshold = isLargeText ? 3.0 : 4.5;
|
||||||
if (ratio < threshold) {
|
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
|
// 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) {
|
function checkHeroEyebrow(opts) {
|
||||||
const {
|
const {
|
||||||
headingTag, headingText, headingFontSize,
|
headingTag, headingText, headingFontSize,
|
||||||
siblingTag, siblingText, siblingTextTransform,
|
siblingTag, siblingText, siblingTextTransform,
|
||||||
siblingFontSize, siblingLetterSpacing,
|
siblingFontSize, siblingLetterSpacing,
|
||||||
|
siblingFontWeight, siblingColor,
|
||||||
} = opts;
|
} = opts;
|
||||||
if (headingTag !== 'h1') return [];
|
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 [];
|
if (!siblingTag) return [];
|
||||||
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
|
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
|
||||||
// headings) — never an eyebrow.
|
// headings) — never an eyebrow.
|
||||||
if (HEADING_TAGS.has(siblingTag)) return [];
|
if (HEADING_TAGS.has(siblingTag)) return [];
|
||||||
|
|
||||||
const text = (siblingText || '').trim();
|
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
|
// Branch A: classic tracked-uppercase eyebrow.
|
||||||
// uppercase (no lowercase letters, at least one uppercase letter).
|
|
||||||
const isUppercased = siblingTextTransform === 'uppercase'
|
const isUppercased = siblingTextTransform === 'uppercase'
|
||||||
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
|
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
|
||||||
if (!isUppercased) return [];
|
const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
|
||||||
|
|
||||||
if (!(siblingLetterSpacing >= 1.6)) return [];
|
// Branch B: modern accent-bold eyebrow — sentence case, low
|
||||||
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
|
// 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 headingTextSnippet = (headingText || '').trim().slice(0, 60);
|
||||||
const eyebrowSnippet = text.slice(0, 40);
|
const eyebrowSnippet = text.slice(0, 40);
|
||||||
|
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
|
||||||
return [{
|
return [{
|
||||||
id: 'hero-eyebrow-chip',
|
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;
|
return bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBackground(el, win) {
|
function resolveBackground(el, win, customPropMap) {
|
||||||
let current = el;
|
let current = el;
|
||||||
while (current && current.nodeType === 1) {
|
while (current && current.nodeType === 1) {
|
||||||
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
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 || '';
|
const bgImage = style.backgroundImage || '';
|
||||||
if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
|
const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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);
|
let bg = parseRgb(style.backgroundColor);
|
||||||
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
|
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
|
||||||
// jsdom doesn't decompose background shorthand — parse raw style attr
|
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
|
||||||
const rawStyle = current.getAttribute?.('style') || '';
|
// through customPropMap so Tailwind v4 color tokens become RGB.
|
||||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
if (customPropMap) {
|
||||||
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
||||||
// Check for gradient or url() image in inline style too
|
}
|
||||||
if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
|
if (!bg || bg.a < 0.1) {
|
||||||
bg = parseRgb(inlineBg);
|
// Inline-style fallback. jsdom doesn't decompose background
|
||||||
if (!bg && inlineBg) {
|
// shorthand, so colors set via inline style are otherwise invisible.
|
||||||
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
|
const rawStyle = current.getAttribute?.('style') || '';
|
||||||
if (hexMatch) {
|
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||||
const h = hexMatch[1];
|
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
||||||
if (h.length === 6) {
|
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
||||||
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
|
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
||||||
} 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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bg && bg.a > 0.1) {
|
if (bg && bg.a > 0.1) {
|
||||||
if (IS_BROWSER || bg.a >= 0.5) return bg;
|
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;
|
current = current.parentElement;
|
||||||
}
|
}
|
||||||
return { r: 255, g: 255, b: 255 };
|
return { r: 255, g: 255, b: 255 };
|
||||||
@@ -1243,9 +1344,144 @@ function checkElementHeroEyebrowDOM(el) {
|
|||||||
siblingTextTransform: sibStyle.textTransform || '',
|
siblingTextTransform: sibStyle.textTransform || '',
|
||||||
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
||||||
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 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 = [
|
const REPEATED_KICKER_SKIP_SELECTOR = [
|
||||||
'nav',
|
'nav',
|
||||||
'form',
|
'form',
|
||||||
@@ -1497,7 +1733,7 @@ function resolveLengthPx(value, fontSizePx) {
|
|||||||
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
||||||
// before calling this so the pure function only deals with numbers.
|
// before calling this so the pure function only deals with numbers.
|
||||||
function checkQuality(opts) {
|
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 = [];
|
const findings = [];
|
||||||
// Skip browser extension injected elements
|
// Skip browser extension injected elements
|
||||||
const elId = el.id || '';
|
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 ---
|
// --- Tight line height ---
|
||||||
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||||
if (lineHeightPx != null && fontSize > 0) {
|
if (lineHeightPx != null && fontSize > 0) {
|
||||||
@@ -1609,7 +1879,8 @@ function checkElementQualityDOM(el) {
|
|||||||
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
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
|
// 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);
|
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 directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||||
const hasDirectText = directText.trim().length > 0;
|
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({
|
return checkColors({
|
||||||
tag,
|
tag,
|
||||||
textColor: parseRgb(style.color),
|
textColor,
|
||||||
bgColor: readOwnBackgroundColor(el, style),
|
bgColor: readOwnBackgroundColor(el, style),
|
||||||
effectiveBg,
|
effectiveBg,
|
||||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
|
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 [];
|
if (tag !== 'h1') return [];
|
||||||
const sibling = el.previousElementSibling;
|
const sibling = el.previousElementSibling;
|
||||||
if (!sibling) return [];
|
if (!sibling) return [];
|
||||||
const sibStyle = window.getComputedStyle(sibling);
|
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
|
// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
|
||||||
// gate falls through cleanly. jsdom returns letter-spacing verbatim
|
// gate falls through cleanly. jsdom returns letter-spacing verbatim
|
||||||
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
|
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
|
||||||
return checkHeroEyebrow({
|
return checkHeroEyebrow({
|
||||||
headingTag: tag,
|
headingTag: tag,
|
||||||
headingText: el.textContent || '',
|
headingText: el.textContent || '',
|
||||||
headingFontSize: parseFloat(style.fontSize) || 0,
|
headingFontSize: parseFloat(headingFontSizeRaw) || 0,
|
||||||
siblingTag: sibling.tagName.toLowerCase(),
|
siblingTag: sibling.tagName.toLowerCase(),
|
||||||
siblingText: sibling.textContent || '',
|
siblingText: sibling.textContent || '',
|
||||||
siblingTextTransform: sibStyle.textTransform || '',
|
siblingTextTransform: sibStyle.textTransform || '',
|
||||||
siblingFontSize,
|
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;
|
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)
|
// 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, {
|
const dom = new JSDOM(processedHtml, {
|
||||||
url: `file://${resolvedPath}`,
|
url: `file://${resolvedPath}`,
|
||||||
});
|
});
|
||||||
@@ -3087,6 +3453,39 @@ async function detectHtml(filePath) {
|
|||||||
// by the border check adapter as a fallback.
|
// by the border check adapter as a fallback.
|
||||||
const borderOverrides = buildBorderOverrideMap(document, window);
|
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)
|
// Element-level checks (borders + colors + motion)
|
||||||
for (const el of document.querySelectorAll('*')) {
|
for (const el of document.querySelectorAll('*')) {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
@@ -3095,10 +3494,10 @@ async function detectHtml(filePath) {
|
|||||||
for (const f of checkElementBorders(tag, style, borderOverrides.get(el), resolvedRadius)) {
|
for (const f of checkElementBorders(tag, style, borderOverrides.get(el), resolvedRadius)) {
|
||||||
findings.push(finding(f.id, filePath, f.snippet));
|
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));
|
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));
|
findings.push(finding(f.id, filePath, f.snippet));
|
||||||
}
|
}
|
||||||
for (const f of checkElementMotion(tag, style)) {
|
for (const f of checkElementMotion(tag, style)) {
|
||||||
@@ -3110,7 +3509,7 @@ async function detectHtml(filePath) {
|
|||||||
for (const f of checkElementItalicSerif(el, style, tag)) {
|
for (const f of checkElementItalicSerif(el, style, tag)) {
|
||||||
findings.push(finding(f.id, filePath, f.snippet));
|
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));
|
findings.push(finding(f.id, filePath, f.snippet));
|
||||||
}
|
}
|
||||||
for (const f of checkElementQuality(el, style, tag, window)) {
|
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`);
|
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
|
||||||
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
|
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