fix(ai): button icon contrast resolves \$color refs + overrides low-contrast icons

User reported icons inside accent-color buttons rendering dark while
text on the same button rendered white — visible on the "Burger" tab,
the "Order now" CTA, and the round filter icon-button in the food-app
generation. Two compounding causes in `fixButtonForegroundContrast`:

1. The luminance check ran on the raw `fill[0].color` even when that
   was a `\$color-accent` variable ref. parseInt('\$c…', 16) returns
   NaN, NaN<0.5 evaluates to false, the dark-fg branch wins, and the
   contrast pass was painting dark-on-orange. Resolve the ref via
   the doc store's variables + active theme before luminance.
   (NaN luminance now falls through to the white branch — better
   than the previous silent dark default when resolution misses.)

2. The icon_font child path skipped any node that "already has a
   visible fill". Models reflexively stamp `icon_font.fill` to a
   dark text color (the prompt lists `fill` as a property), so on
   an accent button the contrast pass left the dark icon next to
   the now-white text. Split the icon_font branch off the text
   branch: when the icon already has a fill, RESOLVE it and check
   contrast against the (resolved) bg; if the luminance delta is
   below the WCAG-graphical threshold (0.4), override with the
   contrast fg.

Why not unconditionally override icon_font fill: an intentional
brand-color icon on a near-white button (red notification dot, blue
brand mark) has a contrast delta well above 0.4 and survives. Only
the dark-on-dark / light-on-light pairs that motivated the bug get
rewritten.

`text` and `path` branches keep their original behavior — text is
where models intentionally express accent colors, and path stroke
icons get filled by the existing stroke-fallback path.
This commit is contained in:
Fini 2026-05-05 12:22:46 +08:00
parent 9fc9c4409f
commit 0548ea18f7

View file

@ -1,6 +1,8 @@
import type { PenNode, FrameNode, SizingBehavior } from '@/types/pen';
import type { PathNode } from '@/types/pen';
import type { PenFill, PenStroke, PenEffect, SolidFill } from '@/types/styles';
import { resolveColorRef, getDefaultTheme } from '@zseven-w/pen-core';
import { useDocumentStore } from '@/stores/document-store';
import {
toSizeNumber,
toGapNumber,
@ -11,6 +13,25 @@ import {
} from './generation-utils';
import { resolveIconPathBySemanticName } from './icon-resolver';
/**
* Resolve a color string that may be a `$color-*` variable ref into the
* concrete hex it points at on the active theme. Returns the original
* string when it isn't a ref or when resolution fails. Reads the live
* doc store directly because the role resolver runs without an
* explicit variables param threaded through every helper.
*/
function resolveColorMaybeRef(color: string | undefined): string | undefined {
if (color === undefined) return undefined;
if (!color.startsWith('$')) return color;
const doc = useDocumentStore.getState().document;
const variables = doc.variables;
if (!variables || Object.keys(variables).length === 0) return color;
const themes = doc.themes;
const activeTheme = themes ? getDefaultTheme(themes) : undefined;
const resolved = resolveColorRef(color, variables, activeTheme);
return typeof resolved === 'string' ? resolved : color;
}
// ---------------------------------------------------------------------------
// Context passed to each role rule function
// ---------------------------------------------------------------------------
@ -738,11 +759,24 @@ function fixButtonForegroundContrast(parent: FrameNode): void {
// white on an invisible button.
if (!hasVisibleFill(parent)) return;
const bgColor = getFirstSolidColor(parent);
const bgColorRaw = getFirstSolidColor(parent);
if (!bgColorRaw) return;
// The model emits accent-colored buttons as `$color-accent`, not
// hex. Without resolving the ref the luminance check sees a literal
// `$color-...` string, parseInt returns NaN, `NaN < 0.5` is false,
// and the dark-fg branch wins — so on an orange accent button the
// contrast pass paints text dark-on-orange. Use the resolved hex
// (or the literal when not a ref) for luminance.
const bgColor = resolveColorMaybeRef(bgColorRaw);
if (!bgColor) return;
const lum = hexLuminance(bgColor);
const fgColor = lum < 0.5 ? '#FFFFFF' : '#0F172A';
// Treat unparseable luminance (NaN — e.g. when the variable was not
// seeded and resolution returned the original ref) as a dark bg so
// the contrast pass at least paints visible white text instead of
// dark-on-unknown. Better default than the previous silent NaN<0.5
// branch which always picked the dark fg.
const fgColor = !Number.isFinite(lum) || lum < 0.5 ? '#FFFFFF' : '#0F172A';
const fgFill: PenFill[] = [{ type: 'solid', color: fgColor }];
if (!('children' in parent) || !Array.isArray(parent.children)) return;
@ -750,13 +784,32 @@ function fixButtonForegroundContrast(parent: FrameNode): void {
for (const child of parent.children) {
const rec = child as unknown as Record<string, unknown>;
if (child.type === 'text' || child.type === 'icon_font') {
if (child.type === 'text') {
// `hasVisibleFill` treats transparent-hex placeholder fills as
// unfilled, so the normalizer's #00000000 leftover does not
// block contrast from supplying a visible color.
if (!hasVisibleFill(child)) {
rec.fill = fgFill;
}
} else if (child.type === 'icon_font') {
// Icons get the contrast fg even when they already have a fill.
// The model defaults `icon_font.fill` to a dark text-color (the
// prompt lists `fill` as a property, models reflexively stamp
// a dark hex) — but inside an accent-colored button that paints
// a dark icon next to a contrast-corrected white text label,
// which is the visible regression. ONLY override when the
// existing icon fill has poor contrast against the bg; an
// intentional brand-color icon on a white button (e.g. a red
// notification dot) survives untouched.
if (!hasVisibleFill(child)) {
rec.fill = fgFill;
} else {
const existing = getFirstSolidColor(child);
const existingHex = existing ? resolveColorMaybeRef(existing) : undefined;
if (existingHex && needsContrastOverride(existingHex, bgColor)) {
rec.fill = fgFill;
}
}
} else if (child.type === 'path') {
const hasStroke = 'stroke' in child && child.stroke != null;
const hasStrokeFill =
@ -775,6 +828,22 @@ function fixButtonForegroundContrast(parent: FrameNode): void {
}
}
/**
* Return true when a foreground color has poor contrast against a
* background color and should be replaced by the contrast pass. The
* threshold (luminance delta < 0.4) catches dark-on-dark and
* light-on-light pairs while keeping intentional accent icons
* (e.g. red badge dot on white card, brand-blue icon on white button)
* untouched. Both inputs must already be hex; ref resolution happens
* at the caller.
*/
function needsContrastOverride(fgHex: string, bgHex: string): boolean {
const fgLum = hexLuminance(fgHex);
const bgLum = hexLuminance(bgHex);
if (!Number.isFinite(fgLum) || !Number.isFinite(bgLum)) return false;
return Math.abs(fgLum - bgLum) < 0.4;
}
const SECTION_ROLES = new Set(['section', 'hero', 'cta-section', 'stats-section', 'footer']);
const ALTERNATING_BG = ['#FFFFFF', '#F8FAFC'];