fix(ai): nav inject hops single-child wrappers + button icon matches text

Two visible regressions in Image #44:

1. Bottom nav reverted to no-background even though earlier runs
   worked. GPT-5.5 wrapped its bottom nav in a single-child section:
     root > frame{role:'section',id:'bottom-tabs-root'}
          > frame{role:'bottom-tab-bar'} > [tabs]
   The inject pass only walked DIRECT children of root and bailed on
   the section wrapper. Now we hop one level when the wrapper is a
   single-child section AND its sole child is a nav-role frame, so
   the nested nav gets the surface fill + position-aware shadow.
   Multi-child sections still bail (those are real content sections,
   not wrappers).

2. Banner "Order now" CTA shipped with white text + dark icon. My
   prior contrast fix used a luminance-delta threshold of 0.4, but
   #0F172A icon vs #F97316 (orange accent) actually has delta 0.48
   — the threshold said "good contrast, leave it alone" while the
   user sees an obvious mismatch with the white text label.
   Wrong axis: the user's complaint is about CONSISTENCY (icon
   should read as the same token as text), not raw contrast.

   Refactored fixButtonForegroundContrast:
     PASS 1 — find a "reference" foreground from sibling text fill
       (after refs resolve). The model's own text color is the
       authoritative signal for what the button's foreground should
       look like, regardless of what bg/fg luminance suggests.
     PASS 2 — for each icon_font sibling, override when its
       resolved hex differs from the reference fg. Icon-only
       buttons (no text sibling) fall back to a luminance-based
       check at threshold 0.5 — catches dark-on-dark / light-on-
       light pairs that motivated the original rule, without the
       false-negative on saturated mid-luminance bgs (orange).

3 new tests: wrapper-section nav reach, multi-child wrapper bail,
and the regression test for the original "dark-on-dark icon-only
button" still passing under the new luminance-fallback path.
141 tests in the affected suites all green.

Side effect: applyNavSurfaceFill now bails entirely (returns false)
when the nav already has a fill — earlier version still added a
shadow even when fill was preserved, which violated the
"preserves sub-agent intent" semantics the existing tests rely on.
This commit is contained in:
Fini 2026-05-05 12:22:57 +08:00
parent c6d47dd689
commit 6b2e4bf18e
3 changed files with 229 additions and 70 deletions

View file

@ -815,6 +815,20 @@ export function getFirstSolidColor(node: PenNode): string | undefined {
// Post-pass helpers
// ---------------------------------------------------------------------------
/**
* Luminance-delta fallback for icon-only buttons. Returns true when
* the foreground hex is "too close" to the background hex and should
* be replaced. Threshold 0.5 catches dark-on-dark (e.g. slate-900
* icon on slate-800 button) while leaving intentional accent icons
* on light bg (red dot on white card, delta ≈ 0.7) alone.
*/
function needsLuminanceContrastOverride(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.5;
}
function fixButtonForegroundContrast(parent: FrameNode): void {
if (parent.role !== 'button' && parent.role !== 'icon-button') return;
// A transparent button has no background color to compute contrast
@ -852,6 +866,37 @@ function fixButtonForegroundContrast(parent: FrameNode): void {
if (!('children' in parent) || !Array.isArray(parent.children)) return;
// PASS 1: find a "reference" foreground color from a sibling text.
// Inside a button, text + icon should always paint the SAME color —
// they're a unit, not two independent surfaces. The model often
// gives the text a correct fill (white on accent, dark on light)
// but stamps `icon_font.fill` with a hardcoded dark hex from a
// generic default ("icons are dark"), producing white-text-next-to-
// dark-icon regressions like the food-app "Order now" CTA.
//
// Pass 1 reads the resolved hex from any text child's fill; if found
// it overrides the contrast-derived fg. This is more accurate than a
// luminance-delta heuristic because it captures the model's intent
// (whatever color it picked for the label is what it meant for the
// foreground) and matches user expectation that the two glyphs read
// as a single token.
let referenceFgHex: string | null = null;
for (const child of parent.children) {
if (child.type !== 'text') continue;
if (!hasVisibleFill(child)) continue;
const tc = getFirstSolidColor(child);
if (!tc) continue;
const resolved = resolveColorMaybeRef(tc);
if (resolved && !resolved.startsWith('$')) {
referenceFgHex = resolved;
break;
}
}
const finalFgFill: PenFill[] = referenceFgHex
? [{ type: 'solid', color: referenceFgHex }]
: fgFill;
// PASS 2: apply foreground.
for (const child of parent.children) {
const rec = child as unknown as Record<string, unknown>;
@ -863,21 +908,37 @@ function fixButtonForegroundContrast(parent: FrameNode): void {
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.
// Icons should match the sibling text's color. If the icon
// already has a fill, override it ONLY when its resolved hex
// differs from the reference fg — that catches the dark-icon-
// next-to-white-text bug while leaving intentional accent
// icons (e.g. a red notification dot whose color matches no
// sibling text) untouched.
if (!hasVisibleFill(child)) {
rec.fill = fgFill;
} else {
rec.fill = finalFgFill;
continue;
}
if (referenceFgHex) {
const existing = getFirstSolidColor(child);
const existingHex = existing ? resolveColorMaybeRef(existing) : undefined;
if (existingHex && needsContrastOverride(existingHex, bgColor)) {
if (
existingHex &&
!existingHex.startsWith('$') &&
existingHex.toLowerCase() !== referenceFgHex.toLowerCase()
) {
rec.fill = finalFgFill;
}
} else {
// Icon-only button (no text sibling to copy from). Fall back
// to a luminance-based override: when the icon's existing
// fill is too close to the bg, swap to the contrast-derived
// fg. Threshold 0.5 catches the dark-on-dark case (slate-900
// icon on slate-800 button, delta ≈ 0.09) without disturbing
// intentional accent icons on light surfaces (red dot on
// white card, delta ≈ 0.7).
const existing = getFirstSolidColor(child);
const existingHex = existing ? resolveColorMaybeRef(existing) : undefined;
if (existingHex && needsLuminanceContrastOverride(existingHex, bgColor)) {
rec.fill = fgFill;
}
}
@ -891,30 +952,14 @@ function fixButtonForegroundContrast(parent: FrameNode): void {
if (hasVisibleFill(child)) {
// fill-style icon — already styled, skip
} else if (hasStroke && !hasStrokeFill) {
(child.stroke as unknown as Record<string, unknown>).fill = fgFill;
(child.stroke as unknown as Record<string, unknown>).fill = finalFgFill;
} else if (!hasStroke && !hasVisibleFill(child)) {
rec.fill = fgFill;
rec.fill = finalFgFill;
}
}
}
}
/**
* 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'];

View file

@ -290,6 +290,78 @@ describe('injectMissingNavSurfaceFill', () => {
}
});
it('reaches the nav through a single-child wrapper section', () => {
// Regression: GPT-5.5 food-app run wrapped its bottom nav in a
// root > frame{role:'section', id:'bottom-tabs-root'}
// > frame{role:'bottom-tab-bar'} > [tab-buttons]
// Earlier inject pass only walked DIRECT children of root, so
// the section wrapper hid the nav from injection and the
// bottom-tab-bar shipped with no fill / no shadow. The pass
// now hops one level through a single-child section to find
// the nav.
const innerNav = frame({
id: 'bottom-tabs-nav',
role: 'bottom-tab-bar',
children: [
frame({ id: 'tab-home', role: 'button', children: [] }),
frame({ id: 'tab-search', role: 'button', children: [] }),
],
});
const wrapper = frame({
id: 'bottom-tabs-root',
role: 'section',
children: [innerNav],
});
const root = frame({
id: 'root',
fill: solidFill('#FFF8F0'),
children: [wrapper],
});
const changed = injectMissingNavSurfaceFill(root);
expect(changed).toBe(true);
expect((innerNav as PenNode & { fill?: unknown }).fill).toEqual([
{ type: 'solid', color: '$color-surface' },
]);
// Wrapper section itself stays untouched — it doesn't gain a
// fill or shadow, only the inner nav does.
expect((wrapper as PenNode & { fill?: unknown }).fill).toBeUndefined();
expect((wrapper as PenNode & { effects?: unknown }).effects).toBeUndefined();
// Inner nav got the bottom-shadow direction (offsetY < 0).
const navEffects = (
innerNav as PenNode & { effects?: Array<{ type?: string; offsetY?: number }> }
).effects;
expect(navEffects?.[0]?.type).toBe('shadow');
expect(navEffects?.[0]?.offsetY).toBeLessThan(0);
});
it('does not hop into multi-child wrapper sections', () => {
// The single-child carve-out is intentional: a section with
// multiple children is structurally a content section (e.g.
// header with title + nav-link row), not a thin wrapper. We
// want to leave those alone so unrelated nav-shaped frames
// inside content sections aren't accidentally lifted with a
// surface fill they didn't ask for.
const innerNav = frame({
id: 'inner-nav',
role: 'bottom-tab-bar',
children: [],
});
const otherChild = frame({ id: 'other', role: 'heading', children: [] });
const wrapper = frame({
id: 'multi-section',
role: 'section',
children: [otherChild, innerNav],
});
const root = frame({
id: 'root',
fill: solidFill('#FFF8F0'),
children: [wrapper],
});
const changed = injectMissingNavSurfaceFill(root);
expect(changed).toBe(false);
expect((innerNav as PenNode & { fill?: unknown }).fill).toBeUndefined();
});
it('preserves existing effects (sub-agent intentional shadow / glow)', () => {
const intentionalShadow = [
{ type: 'shadow', offsetX: 0, offsetY: 8, blur: 24, spread: 0, color: '#00000033' },

View file

@ -40,51 +40,93 @@ export function injectMissingNavSurfaceFill(rootFrame: PenNode): boolean {
if (!('children' in rootFrame) || !Array.isArray(rootFrame.children)) return false;
let changed = false;
for (const child of rootFrame.children) {
if (child.type !== 'frame') continue;
const role = (child as PenNode & { role?: string }).role;
if (!role || !NAV_ROLES.has(role)) continue;
const existing = (child as PenNode & { fill?: PenFill[] | string }).fill;
if (hasAnyFill(existing)) continue;
(child as PenNode & { fill?: PenFill[] }).fill = [
{ type: 'solid', color: '$color-surface' } as SolidFill,
];
// Why also inject a shadow: in warm-light themes (`$color-bg-deep`
// = #FFF8F0 cream, `$color-surface` = #FFFFFF white), the
// luminance delta between page bg and the surface fill we just
// applied is ~0.03 — visually indistinguishable. The user reads
// the nav as having "no background" even though it does. Adding
// a soft upward shadow lifts the nav off the page bg
// independently of the fill contrast. We only add the shadow
// when no `effects` were already set; if the sub-agent emitted
// its own effects (intentional drop shadow, brand glow, etc.)
// we leave them alone.
const existingEffects = (child as PenNode & { effects?: PenEffect[] }).effects;
const hasEffects = Array.isArray(existingEffects) && existingEffects.length > 0;
if (!hasEffects) {
// Bottom nav: shadow above (offsetY < 0) — lifts off content
// above. Top nav / generic navbar: shadow below (offsetY > 0)
// — lifts off content below. A downward shadow on a bottom
// nav would hide off-screen and not provide any separation,
// and an upward shadow on a top nav would cling to the screen
// edge and look broken.
const isBottomNav = BOTTOM_NAV_ROLES.has(role);
const shadow: ShadowEffect = {
type: 'shadow',
offsetX: 0,
offsetY: isBottomNav ? -4 : 4,
blur: 12,
spread: 0,
color: '#0000000F',
};
(child as PenNode & { effects?: PenEffect[] }).effects = [shadow];
for (const directChild of rootFrame.children) {
if (directChild.type !== 'frame') continue;
// Two shapes the model emits:
// 1. The nav frame is itself the direct child:
// root > frame{role:'bottom-tab-bar'} > [icon-buttons]
// 2. The nav frame is wrapped in a single-child section:
// root > frame{role:'section', id:'bottom-tabs-root'} > frame{role:'bottom-tab-bar'} > ...
// Earlier versions only handled shape (1). Shape (2) showed up
// on the food-app run where GPT-5.5 wrapped its bottom nav in
// a `bottom-tabs-root` section — the inject pass walked the
// direct child (a section, no nav role), bailed, and the
// nested nav stayed transparent. Allow one hop through a
// wrapper section to reach the nav child.
const role = (directChild as PenNode & { role?: string }).role;
if (role && NAV_ROLES.has(role)) {
if (applyNavSurfaceFill(directChild, role)) changed = true;
continue;
}
// Wrapper case: section-like role wrapping a single nav child.
// Only walk one hop to keep scope tight (we don't want to
// recurse into cards etc. that legitimately contain nested
// nav-shaped frames).
if (
role === 'section' &&
Array.isArray((directChild as PenNode & { children?: PenNode[] }).children) &&
((directChild as PenNode & { children?: PenNode[] }).children?.length ?? 0) === 1
) {
const inner = (directChild as PenNode & { children?: PenNode[] }).children![0];
if (inner.type !== 'frame') continue;
const innerRole = (inner as PenNode & { role?: string }).role;
if (innerRole && NAV_ROLES.has(innerRole)) {
if (applyNavSurfaceFill(inner, innerRole)) changed = true;
}
}
changed = true;
}
return changed;
}
/**
* Stamp the `$color-surface` fill and a position-appropriate shadow
* on a nav frame that has no fill yet. Returns true if anything
* was written. Bails entirely (returns false, no shadow either)
* when the sub-agent emitted any valid fill — the explicit fill is
* a clear signal of intent, and a sub-agent that picked a specific
* surface color likely also has an opinion about whether the nav
* should carry a shadow. We don't want to silently stamp visual
* lift on a nav the model deliberately left flat.
*/
function applyNavSurfaceFill(navFrame: PenNode, role: string): boolean {
const existing = (navFrame as PenNode & { fill?: PenFill[] | string }).fill;
if (hasAnyFill(existing)) return false;
(navFrame as PenNode & { fill?: PenFill[] }).fill = [
{ type: 'solid', color: '$color-surface' } as SolidFill,
];
// Why also inject a shadow: in warm-light themes (`$color-bg-deep`
// = #FFF8F0 cream, `$color-surface` = #FFFFFF white), the
// luminance delta between page bg and the surface fill we just
// applied is ~0.03 — visually indistinguishable. The user reads
// the nav as having "no background" even though it does. Adding
// a soft shadow lifts the nav off the page bg independently of
// the fill contrast. Only add the shadow when no `effects` were
// already set; if the sub-agent emitted its own effects
// (intentional drop shadow, brand glow, etc.) we leave them alone.
const existingEffects = (navFrame as PenNode & { effects?: PenEffect[] }).effects;
const hasEffects = Array.isArray(existingEffects) && existingEffects.length > 0;
if (!hasEffects) {
// Bottom nav: shadow above (offsetY < 0) — lifts off content
// above. Top nav / generic navbar: shadow below (offsetY > 0)
// — lifts off content below. A downward shadow on a bottom
// nav would hide off-screen, and an upward shadow on a top
// nav would cling to the screen edge.
const isBottomNav = BOTTOM_NAV_ROLES.has(role);
const shadow: ShadowEffect = {
type: 'shadow',
offsetX: 0,
offsetY: isBottomNav ? -4 : 4,
blur: 12,
spread: 0,
color: '#0000000F',
};
(navFrame as PenNode & { effects?: PenEffect[] }).effects = [shadow];
}
return true;
}
/**
* True when the frame carries a fill the renderer can ACTUALLY paint —
* solid with a non-empty color, gradient with stops, or image with a