mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
9fc9c4409f
commit
0548ea18f7
1 changed files with 72 additions and 3 deletions
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue