From 08bc403f4e7f68a773dacb3b39dbf2d8e38aca56 Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 5 May 2026 12:22:52 +0800 Subject: [PATCH] fix(pen-core): nav inject also stamps a separating shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The food-app run on warm-light theme shipped a bottom-tab-bar with a valid \$color-surface (white) fill, but the page bg (\$color-bg-deep) is cream #FFF8F0. The luminance delta between white and cream is ~0.03 — visually indistinguishable, so the user reads the nav as having no background even though it does. Image #40 made this concrete: the nav fill landed correctly per live-doc inspection, but the screenshot still showed icons floating over an unbroken cream background. The inject pass already set the surface fill. To survive the low-fill-contrast case we also stamp a soft shadow: - bottom-tab-bar → upward shadow (offsetY: -4) lifts the nav off the content above. A downward shadow would clip off-screen. - top-app-bar / top-nav-bar / navbar → downward shadow (offsetY: 4). An upward shadow would cling to the screen edge. - nav / tab-bar / tab-row → ambiguous position, default downward. Shadow specs (offsetY: ±4, blur: 12, spread: 0, color: #0000000F) match conventional iOS/Android nav lift values and survive on ANY page bg color, not just cream — even on dark themes the extra subtle shadow is invisible (already-dark page) without breaking the design. Existing effects on the nav are preserved — sub-agents that intentionally emit a drop-shadow / glow keep their declaration. 3 new tests cover: bottom-nav gets upward shadow, top-nav variants get downward shadow, sub-agent's existing effects survive the inject pass. --- .../__tests__/inject-nav-surface-fill.test.ts | 65 +++++++++++++++++++ .../src/layout/inject-nav-surface-fill.ts | 39 ++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) 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;