mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
fix: abstain on translucent gradients over images, drop phantom color-mix stops
Two follow-up review findings on the merge with main. A gradient leading a url() layer was treated as a resolvable surface even when its stops are translucent, so the glow and AI-palette hunts averaged wash stops (a 20% black wash reads as pure black) while the real surface blends with image pixels the engine cannot read. resolveBackgroundInfo now marks gradient-over-image unresolved unless every readable stop of the leading gradient is opaque, in which case the gradient provably covers the image and remains the scorable surface. parseGradientColorsModern predated this branch's parseGradientColors rewrite: its second regex pass re-extracted color tokens nested inside color-mix() stops that the shared parser already captures whole via balanced-paren tokens, appending ingredient colors that are never painted. The worst-case stop ratio then invented low-contrast findings against a color nobody sees. The helper is removed; all callers use the shared parser, which covers the modern syntaxes it existed for. Fixture coverage pins both: the translucent-wash-over-image glow abstains in both engines, an opaque gradient over an image still flags in the browser, and the color-mix wash case stays clean in the static engine. Each new assertion was verified to fail against the previous engine. Addresses Greptile and Cursor Bugbot review findings on PR #541. AI-assisted-by: Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3076,13 +3076,30 @@ function resolveBackgroundInfo(el, win, customPropMap) {
|
||||
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
||||
// reported low-contrast against the invisible gradient's stops).
|
||||
if (hasGradientOrUrl) {
|
||||
const topPaintLayer = splitTopLevelCommas(bgImage).find(
|
||||
const layers = splitTopLevelCommas(bgImage);
|
||||
const topPaintLayer = layers.find(
|
||||
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
|
||||
);
|
||||
const gradientOnTop = !!topPaintLayer
|
||||
&& /gradient\s*\(/i.test(topPaintLayer)
|
||||
&& !/^\s*url\s*\(/i.test(topPaintLayer);
|
||||
return { color: null, unresolved: !gradientOnTop };
|
||||
if (!gradientOnTop) return { color: null, unresolved: true };
|
||||
// Gradient on top of a url() layer: the image shows through wherever
|
||||
// the gradient is not fully opaque, so a translucent wash like
|
||||
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
|
||||
// a blend with pixels this engine cannot read. Only a gradient whose
|
||||
// every readable stop is opaque provably covers the image; otherwise
|
||||
// the surface is unknown — abstain rather than hand callers gradient
|
||||
// stops (or a stop average) the visitor never sees unmixed.
|
||||
const urlBeneath = layers.some(
|
||||
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
|
||||
);
|
||||
if (urlBeneath) {
|
||||
const topStops = parseGradientColors(topPaintLayer);
|
||||
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
|
||||
if (!provablyOpaque) return { color: null, unresolved: true };
|
||||
}
|
||||
return { color: null, unresolved: false };
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -3096,21 +3113,6 @@ function resolveBackground(el, win, customPropMap) {
|
||||
return resolveBackgroundInfo(el, win, customPropMap).color;
|
||||
}
|
||||
|
||||
// parseGradientColors (shared) reads only the legacy serializations: rgb()
|
||||
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
|
||||
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
|
||||
// which is what every token-driven page produces. Route those through
|
||||
// parseAnyColor so a gradient ground is measurable rather than invisible.
|
||||
function parseGradientColorsModern(bgImage) {
|
||||
if (!bgImage || !/gradient/i.test(bgImage)) return [];
|
||||
const colors = parseGradientColors(bgImage);
|
||||
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
|
||||
const c = parseAnyColor(m[0]);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -3134,7 +3136,10 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const parsed = parseGradientColorsModern(bgImage);
|
||||
// parseGradientColors (shared) reads modern-space stops too — oklch,
|
||||
// color-mix and friends via balanced-paren token capture — so browser
|
||||
// computed values that keep the authored syntax stay measurable.
|
||||
const parsed = parseGradientColors(bgImage);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
||||
@@ -3142,7 +3147,7 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
const parsed = parseGradientColorsModern(bgMatch[1]);
|
||||
const parsed = parseGradientColors(bgMatch[1]);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
}
|
||||
@@ -3936,11 +3941,13 @@ function checkElementGlowDOM(el) {
|
||||
if (!parentBg && !parentBgInfo.unresolved) {
|
||||
// Gradient background — sample its colors to determine if it's dark.
|
||||
// Modern-syntax parsing matters here: body-level gradients now reach this
|
||||
// fallback in browser mode, and their stops usually serialize as oklch.
|
||||
// fallback in browser mode, and their stops usually serialize as oklch —
|
||||
// which the shared parseGradientColors reads via its color-function
|
||||
// token capture.
|
||||
let cur = el.parentElement;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
||||
const gradColors = parseGradientColorsModern(bgImage);
|
||||
const gradColors = parseGradientColors(bgImage);
|
||||
if (gradColors.length > 0) {
|
||||
// Average the gradient colors
|
||||
const avg = { r: 0, g: 0, b: 0 };
|
||||
|
||||
+28
-21
@@ -1842,13 +1842,30 @@ function resolveBackgroundInfo(el, win, customPropMap) {
|
||||
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
||||
// reported low-contrast against the invisible gradient's stops).
|
||||
if (hasGradientOrUrl) {
|
||||
const topPaintLayer = splitTopLevelCommas(bgImage).find(
|
||||
const layers = splitTopLevelCommas(bgImage);
|
||||
const topPaintLayer = layers.find(
|
||||
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
|
||||
);
|
||||
const gradientOnTop = !!topPaintLayer
|
||||
&& /gradient\s*\(/i.test(topPaintLayer)
|
||||
&& !/^\s*url\s*\(/i.test(topPaintLayer);
|
||||
return { color: null, unresolved: !gradientOnTop };
|
||||
if (!gradientOnTop) return { color: null, unresolved: true };
|
||||
// Gradient on top of a url() layer: the image shows through wherever
|
||||
// the gradient is not fully opaque, so a translucent wash like
|
||||
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
|
||||
// a blend with pixels this engine cannot read. Only a gradient whose
|
||||
// every readable stop is opaque provably covers the image; otherwise
|
||||
// the surface is unknown — abstain rather than hand callers gradient
|
||||
// stops (or a stop average) the visitor never sees unmixed.
|
||||
const urlBeneath = layers.some(
|
||||
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
|
||||
);
|
||||
if (urlBeneath) {
|
||||
const topStops = parseGradientColors(topPaintLayer);
|
||||
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
|
||||
if (!provablyOpaque) return { color: null, unresolved: true };
|
||||
}
|
||||
return { color: null, unresolved: false };
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -1862,21 +1879,6 @@ function resolveBackground(el, win, customPropMap) {
|
||||
return resolveBackgroundInfo(el, win, customPropMap).color;
|
||||
}
|
||||
|
||||
// parseGradientColors (shared) reads only the legacy serializations: rgb()
|
||||
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
|
||||
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
|
||||
// which is what every token-driven page produces. Route those through
|
||||
// parseAnyColor so a gradient ground is measurable rather than invisible.
|
||||
function parseGradientColorsModern(bgImage) {
|
||||
if (!bgImage || !/gradient/i.test(bgImage)) return [];
|
||||
const colors = parseGradientColors(bgImage);
|
||||
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
|
||||
const c = parseAnyColor(m[0]);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -1900,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const parsed = parseGradientColorsModern(bgImage);
|
||||
// parseGradientColors (shared) reads modern-space stops too — oklch,
|
||||
// color-mix and friends via balanced-paren token capture — so browser
|
||||
// computed values that keep the authored syntax stay measurable.
|
||||
const parsed = parseGradientColors(bgImage);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
if (!stops && !DETECTOR_IS_BROWSER) {
|
||||
@@ -1908,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
const rawStyle = current.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
||||
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
||||
const parsed = parseGradientColorsModern(bgMatch[1]);
|
||||
const parsed = parseGradientColors(bgMatch[1]);
|
||||
if (parsed.length > 0) stops = parsed;
|
||||
}
|
||||
}
|
||||
@@ -2702,11 +2707,13 @@ function checkElementGlowDOM(el) {
|
||||
if (!parentBg && !parentBgInfo.unresolved) {
|
||||
// Gradient background — sample its colors to determine if it's dark.
|
||||
// Modern-syntax parsing matters here: body-level gradients now reach this
|
||||
// fallback in browser mode, and their stops usually serialize as oklch.
|
||||
// fallback in browser mode, and their stops usually serialize as oklch —
|
||||
// which the shared parseGradientColors reads via its color-function
|
||||
// token capture.
|
||||
let cur = el.parentElement;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
||||
const gradColors = parseGradientColorsModern(bgImage);
|
||||
const gradColors = parseGradientColors(bgImage);
|
||||
if (gradColors.length > 0) {
|
||||
// Average the gradient colors
|
||||
const avg = { r: 0, g: 0, b: 0 };
|
||||
|
||||
Reference in New Issue
Block a user