fix(pen-core): nav fill inject preserves gradient / image fills

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.
This commit is contained in:
Fini 2026-05-05 02:47:22 +08:00
parent 4212220d57
commit 41f49c66e5
2 changed files with 88 additions and 3 deletions

View file

@ -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',

View file

@ -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';
}