From 41f49c66e5c040eba903f32d230e413dd5499fa5 Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 5 May 2026 02:47:22 +0800 Subject: [PATCH] fix(pen-core): nav fill inject preserves gradient / image fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous `hasSolidFill` only matched `type === 'solid'`. Sub-agents legitimately put `linear_gradient` (sunrise hero, accent ribbon), `radial_gradient` (splash entries), or `image` (branded photo banners) on top app bars and other nav surfaces, and `hasSolidFill` would return false for those — making the inject pass overwrite the gradient/image with a flat `$color-surface` solid. Renamed to `hasAnyFill`; matches any first-entry shape with a recognized `type` field. Sub-agent intent (any non-empty fill) now short-circuits the inject. Three new tests cover linear gradient, radial gradient, and image fills explicitly — all preserved. --- .../__tests__/inject-nav-surface-fill.test.ts | 76 +++++++++++++++++++ .../src/layout/inject-nav-surface-fill.ts | 15 +++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts b/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts index a3877d5e7..7996a4337 100644 --- a/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts +++ b/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts @@ -50,6 +50,82 @@ describe('injectMissingNavSurfaceFill', () => { expect((navbar as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0F172A')); }); + it('preserves a linear-gradient nav fill (does not overwrite with solid)', () => { + const gradientFill = [ + { + type: 'linear_gradient' as const, + stops: [ + { offset: 0, color: '#F97316' }, + { offset: 1, color: '#EA580C' }, + ], + angle: 90, + }, + ]; + const navbar = frame({ + id: 'top-bar', + role: 'top-app-bar', + fill: gradientFill as never, + children: [], + }); + const root = frame({ + id: 'root', + fill: solidFill('#FFFFFF'), + children: [navbar], + }); + const changed = injectMissingNavSurfaceFill(root); + expect(changed).toBe(false); + expect((navbar as PenNode & { fill?: unknown }).fill).toEqual(gradientFill); + }); + + it('preserves a radial-gradient nav fill', () => { + const radial = [ + { + type: 'radial_gradient' as const, + stops: [ + { offset: 0, color: '#FFFFFF' }, + { offset: 1, color: '#000000' }, + ], + center: { x: 0.5, y: 0.5 }, + }, + ]; + const navbar = frame({ + id: 'splash-nav', + role: 'navbar', + fill: radial as never, + children: [], + }); + const root = frame({ + id: 'root', + fill: solidFill('#FFF8F0'), + children: [navbar], + }); + injectMissingNavSurfaceFill(root); + expect((navbar as PenNode & { fill?: unknown }).fill).toEqual(radial); + }); + + it('preserves an image nav fill (branded nav surface with photo)', () => { + const imageFill = [ + { + type: 'image' as const, + src: 'https://example.com/banner.jpg', + scaleMode: 'cover', + }, + ]; + const navbar = frame({ + id: 'hero-nav', + role: 'top-app-bar', + fill: imageFill as never, + children: [], + }); + const root = frame({ + id: 'root', + fill: solidFill('#FFFFFF'), + children: [navbar], + }); + injectMissingNavSurfaceFill(root); + expect((navbar as PenNode & { fill?: unknown }).fill).toEqual(imageFill); + }); + it('does not touch nav frames nested inside cards/sections', () => { const nestedNav = frame({ id: 'inner-nav', diff --git a/packages/pen-core/src/layout/inject-nav-surface-fill.ts b/packages/pen-core/src/layout/inject-nav-surface-fill.ts index 305bcf8fb..cf63f793c 100644 --- a/packages/pen-core/src/layout/inject-nav-surface-fill.ts +++ b/packages/pen-core/src/layout/inject-nav-surface-fill.ts @@ -39,7 +39,7 @@ export function injectMissingNavSurfaceFill(rootFrame: PenNode): boolean { 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 (hasSolidFill(existing)) continue; + if (hasAnyFill(existing)) continue; (child as PenNode & { fill?: PenFill[] }).fill = [ { type: 'solid', color: '$color-surface' } as SolidFill, ]; @@ -48,10 +48,19 @@ export function injectMissingNavSurfaceFill(rootFrame: PenNode): boolean { return changed; } -function hasSolidFill(fill: PenFill[] | string | undefined): boolean { +/** + * True when the frame already carries ANY valid fill the renderer would + * paint — solid OR gradient (linear/radial) OR image. We must NOT + * overwrite a non-solid fill with our default `$color-surface` solid: + * sub-agents legitimately put `linear_gradient` on top app bars + * (sunrise hero gradient), `image` on hero / branded nav surfaces, and + * `radial_gradient` on splash-style entries. Keep their intent. + */ +function hasAnyFill(fill: PenFill[] | string | undefined): boolean { if (!fill) return false; if (typeof fill === 'string') return fill.length > 0; if (!Array.isArray(fill) || fill.length === 0) return false; + // Any first entry with a recognized `type` counts as intentional fill. const first = fill[0]; - return first?.type === 'solid' && typeof (first as SolidFill).color === 'string'; + return !!first && typeof (first as { type?: string }).type === 'string'; }