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 460845409..6560de655 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 @@ -243,4 +243,69 @@ describe('injectMissingNavSurfaceFill', () => { expect(changed).toBe(false); expect((sectionWithoutFill as PenNode & { fill?: unknown }).fill).toBeUndefined(); }); + + it('injects an upward shadow on bottom-tab-bar so it lifts off cream pages', () => { + // Regression: warm-light themes resolve `$color-surface` to white + // and `$color-bg-deep` to cream (#FFF8F0). The luminance delta is + // ~0.03 — the nav looks transparent against the page even with a + // valid surface fill. The inject pass also stamps a soft upward + // shadow on bottom-positioned nav so the separation survives the + // low fill-contrast case. + const nav = frame({ + id: 'bottom-nav', + role: 'bottom-tab-bar', + children: [], + }); + const root = frame({ + id: 'root', + fill: solidFill('#FFF8F0'), + children: [nav], + }); + injectMissingNavSurfaceFill(root); + const effects = (nav as PenNode & { effects?: Array<{ type?: string; offsetY?: number }> }) + .effects; + expect(Array.isArray(effects)).toBe(true); + expect(effects?.[0]?.type).toBe('shadow'); + // Bottom nav → negative offsetY → shadow points up. + expect(effects?.[0]?.offsetY).toBeLessThan(0); + }); + + it('injects a downward shadow on top nav (top-app-bar / top-nav-bar / navbar)', () => { + // Top-positioned nav can't use an upward shadow (it would cling + // to the screen edge). The inject pass picks `offsetY > 0` for + // every non-bottom role. + const topRoles = ['top-app-bar', 'top-nav-bar', 'navbar']; + for (const role of topRoles) { + const nav = frame({ id: `nav-${role}`, role, children: [] }); + const root = frame({ + id: 'root', + fill: solidFill('#FFF8F0'), + children: [nav], + }); + injectMissingNavSurfaceFill(root); + const effects = (nav as PenNode & { effects?: Array<{ type?: string; offsetY?: number }> }) + .effects; + expect(effects?.[0]?.type).toBe('shadow'); + expect(effects?.[0]?.offsetY).toBeGreaterThan(0); + } + }); + + it('preserves existing effects (sub-agent intentional shadow / glow)', () => { + const intentionalShadow = [ + { type: 'shadow', offsetX: 0, offsetY: 8, blur: 24, spread: 0, color: '#00000033' }, + ]; + const nav = frame({ + id: 'nav-with-effects', + role: 'bottom-tab-bar', + effects: intentionalShadow as never, + children: [], + }); + const root = frame({ + id: 'root', + fill: solidFill('#FFF8F0'), + children: [nav], + }); + injectMissingNavSurfaceFill(root); + expect((nav as PenNode & { effects?: unknown }).effects).toEqual(intentionalShadow); + }); }); 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 9ae491f31..4487c24ea 100644 --- a/packages/pen-core/src/layout/inject-nav-surface-fill.ts +++ b/packages/pen-core/src/layout/inject-nav-surface-fill.ts @@ -1,4 +1,4 @@ -import type { PenNode, PenFill, SolidFill } from '@zseven-w/pen-types'; +import type { PenNode, PenFill, PenEffect, ShadowEffect, SolidFill } from '@zseven-w/pen-types'; /** * Inject a default surface fill on top-level navigation frames that lack @@ -30,6 +30,12 @@ const NAV_ROLES = new Set([ 'tab-row', ]); +// Roles that sit at the BOTTOM of the screen — their shadow points +// up so the nav lifts off the content above. Anything else (top nav +// bar, generic navbar) gets a downward shadow lifting it off the +// content below. +const BOTTOM_NAV_ROLES = new Set(['bottom-tab-bar']); + export function injectMissingNavSurfaceFill(rootFrame: PenNode): boolean { if (!('children' in rootFrame) || !Array.isArray(rootFrame.children)) return false; @@ -43,6 +49,37 @@ export function injectMissingNavSurfaceFill(rootFrame: PenNode): boolean { (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]; + } changed = true; } return changed;