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:
parent
2e577b9b00
commit
08bc403f4e
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue