mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
Detect low-contrast and gray text over gradient backgrounds
Previously checkColors bailed out whenever an ancestor used a gradient background, since resolveBackground returned null. As a result, gray or low-contrast text inside any gradient container was completely invisible to both rules — e.g. the gray heading on bad-contrast.html. Add a resolveGradientStops fallback that walks parents for gradient stops and runs contrast against the worst-case stop, plus gray-on-color when every stop is chromatic. parseGradientColors now also accepts hex so jsdom fixtures with raw inline gradients work too. Extended the color-should-flag fixture and tests to cover the gradient case. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
13b2f763d9
commit
b391485e16
@@ -284,9 +284,20 @@ function contrastRatio(c1, c2) {
|
||||
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
|
||||
.map(m => parseRgb(m[0]))
|
||||
.filter(Boolean);
|
||||
const colors = [];
|
||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
||||
const c = parseRgb(m[0]);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
} else {
|
||||
colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
function hasChroma(c, threshold = 30) {
|
||||
@@ -342,7 +353,7 @@ function checkBorders(tag, widths, colors, radius) {
|
||||
}
|
||||
|
||||
function checkColors(opts) {
|
||||
const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
|
||||
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
const findings = [];
|
||||
|
||||
@@ -352,22 +363,28 @@ function checkColors(opts) {
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor) {
|
||||
// Skip background-dependent checks if we can't determine the background (e.g. gradient)
|
||||
if (effectiveBg) {
|
||||
// Gray on colored background
|
||||
// Run background-dependent checks against either a solid bg or, if the
|
||||
// ancestor is a gradient, against every gradient stop (use the worst case).
|
||||
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
|
||||
if (bgs) {
|
||||
// Gray on colored background — flag if every stop is chromatic
|
||||
const textLum = relativeLuminance(textColor);
|
||||
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
|
||||
if (isGray && hasChroma(effectiveBg, 40)) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
|
||||
if (isGray && bgs.every(b => hasChroma(b, 40))) {
|
||||
const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
|
||||
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
|
||||
}
|
||||
|
||||
// Low contrast (WCAG AA)
|
||||
const ratio = contrastRatio(textColor, effectiveBg);
|
||||
// Low contrast (WCAG AA) — worst case across all bg stops
|
||||
const ratios = bgs.map(b => contrastRatio(textColor, b));
|
||||
let worstIdx = 0;
|
||||
for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
|
||||
const ratio = ratios[worstIdx];
|
||||
const isHeading = ['h1', 'h2', 'h3'].includes(tag);
|
||||
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(effectiveBg)}` });
|
||||
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +690,32 @@ function resolveBackground(el, win) {
|
||||
return { r: 255, g: 255, b: 255 };
|
||||
}
|
||||
|
||||
// Walk parents looking for a gradient background and return its color stops.
|
||||
// Used as a fallback when resolveBackground() returns null because the
|
||||
// effective background is a gradient (no single solid color to compare against).
|
||||
function resolveGradientStops(el, win) {
|
||||
let current = el;
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const stops = parseGradientColors(bgImage);
|
||||
if (stops.length > 0) return stops;
|
||||
}
|
||||
if (!IS_BROWSER) {
|
||||
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
const stops = parseGradientColors(bgMatch[1]);
|
||||
if (stops.length > 0) return stops;
|
||||
}
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Section 5: Element Adapters ────────────────────────────────────────────
|
||||
|
||||
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
|
||||
@@ -699,11 +742,13 @@ function checkElementColorsDOM(el) {
|
||||
if (rect.width < 10 || rect.height < 10) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
const effectiveBg = resolveBackground(el);
|
||||
return checkColors({
|
||||
tag,
|
||||
textColor: parseRgb(style.color),
|
||||
bgColor: parseRgb(style.backgroundColor),
|
||||
effectiveBg: resolveBackground(el),
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -949,11 +994,13 @@ function checkElementColors(el, style, tag, window) {
|
||||
const hasText = el.textContent?.trim().length > 0;
|
||||
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
|
||||
const effectiveBg = resolveBackground(el, window);
|
||||
return checkColors({
|
||||
tag,
|
||||
textColor: parseRgb(style.color),
|
||||
bgColor: parseRgb(style.backgroundColor),
|
||||
effectiveBg: resolveBackground(el, window),
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
|
||||
+61
-14
@@ -279,9 +279,20 @@ function contrastRatio(c1, c2) {
|
||||
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
|
||||
.map(m => parseRgb(m[0]))
|
||||
.filter(Boolean);
|
||||
const colors = [];
|
||||
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
|
||||
const c = parseRgb(m[0]);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
} else {
|
||||
colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
function hasChroma(c, threshold = 30) {
|
||||
@@ -337,7 +348,7 @@ function checkBorders(tag, widths, colors, radius) {
|
||||
}
|
||||
|
||||
function checkColors(opts) {
|
||||
const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
|
||||
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
const findings = [];
|
||||
|
||||
@@ -347,22 +358,28 @@ function checkColors(opts) {
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor) {
|
||||
// Skip background-dependent checks if we can't determine the background (e.g. gradient)
|
||||
if (effectiveBg) {
|
||||
// Gray on colored background
|
||||
// Run background-dependent checks against either a solid bg or, if the
|
||||
// ancestor is a gradient, against every gradient stop (use the worst case).
|
||||
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
|
||||
if (bgs) {
|
||||
// Gray on colored background — flag if every stop is chromatic
|
||||
const textLum = relativeLuminance(textColor);
|
||||
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
|
||||
if (isGray && hasChroma(effectiveBg, 40)) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
|
||||
if (isGray && bgs.every(b => hasChroma(b, 40))) {
|
||||
const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
|
||||
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
|
||||
}
|
||||
|
||||
// Low contrast (WCAG AA)
|
||||
const ratio = contrastRatio(textColor, effectiveBg);
|
||||
// Low contrast (WCAG AA) — worst case across all bg stops
|
||||
const ratios = bgs.map(b => contrastRatio(textColor, b));
|
||||
let worstIdx = 0;
|
||||
for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
|
||||
const ratio = ratios[worstIdx];
|
||||
const isHeading = ['h1', 'h2', 'h3'].includes(tag);
|
||||
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(effectiveBg)}` });
|
||||
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,6 +685,32 @@ function resolveBackground(el, win) {
|
||||
return { r: 255, g: 255, b: 255 };
|
||||
}
|
||||
|
||||
// Walk parents looking for a gradient background and return its color stops.
|
||||
// Used as a fallback when resolveBackground() returns null because the
|
||||
// effective background is a gradient (no single solid color to compare against).
|
||||
function resolveGradientStops(el, win) {
|
||||
let current = el;
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const stops = parseGradientColors(bgImage);
|
||||
if (stops.length > 0) return stops;
|
||||
}
|
||||
if (!IS_BROWSER) {
|
||||
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
const stops = parseGradientColors(bgMatch[1]);
|
||||
if (stops.length > 0) return stops;
|
||||
}
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Section 5: Element Adapters ────────────────────────────────────────────
|
||||
|
||||
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
|
||||
@@ -694,11 +737,13 @@ function checkElementColorsDOM(el) {
|
||||
if (rect.width < 10 || rect.height < 10) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
const effectiveBg = resolveBackground(el);
|
||||
return checkColors({
|
||||
tag,
|
||||
textColor: parseRgb(style.color),
|
||||
bgColor: parseRgb(style.backgroundColor),
|
||||
effectiveBg: resolveBackground(el),
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
@@ -944,11 +989,13 @@ function checkElementColors(el, style, tag, window) {
|
||||
const hasText = el.textContent?.trim().length > 0;
|
||||
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
|
||||
const effectiveBg = resolveBackground(el, window);
|
||||
return checkColors({
|
||||
tag,
|
||||
textColor: parseRgb(style.color),
|
||||
bgColor: parseRgb(style.backgroundColor),
|
||||
effectiveBg: resolveBackground(el, window),
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
|
||||
fontSize: parseFloat(style.fontSize) || 16,
|
||||
fontWeight: parseInt(style.fontWeight) || 400,
|
||||
hasDirectText,
|
||||
|
||||
@@ -47,6 +47,15 @@ describe('detectHtml — jsdom fixtures', () => {
|
||||
assert.ok(f.some(r => r.antipattern === 'low-contrast'));
|
||||
assert.ok(f.some(r => r.antipattern === 'gradient-text'));
|
||||
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'));
|
||||
// Gray/low-contrast text on a gradient background should also be flagged
|
||||
assert.ok(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /Welcome to Our Platform/i.test(r.snippet || '') === false && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
|
||||
'expected low-contrast finding for gray heading on blue/purple gradient',
|
||||
);
|
||||
assert.ok(
|
||||
f.some(r => r.antipattern === 'gray-on-color' && /gradient/i.test(r.snippet || '')),
|
||||
'expected gray-on-color finding referencing gradient',
|
||||
);
|
||||
});
|
||||
|
||||
it('color-should-pass: zero findings', async () => {
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
<div class="card" style="background: rgb(30, 30, 30);">
|
||||
<p style="color: rgb(80, 80, 80);">Dark gray text on near-black — low contrast</p>
|
||||
</div>
|
||||
<!-- Gradient background — gray heading is unreadable across every stop -->
|
||||
<div class="card" style="background: linear-gradient(180deg, #3b82f6 0%, #8b5cf6 100%); padding: 1rem;">
|
||||
<h1 style="color: #808080; font-size: 48px; font-weight: 800;">Welcome to Our Platform</h1>
|
||||
<p style="color: #666666; font-size: 18px;">The best solution for modern teams and businesses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Gradient text -->
|
||||
|
||||
Reference in New Issue
Block a user