fix(pen-core): nav inject also stamps a separating shadow

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.
This commit is contained in:
Fini 2026-05-05 12:22:52 +08:00
parent 2e577b9b00
commit 08bc403f4e
2 changed files with 103 additions and 1 deletions

View file

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

View file

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