fix(pen-core): convert stacked-overlay heroes to layout=none

M2.7 food-app run shipped a hero whose content piled into the next
section. Live doc inspection showed:

  hero-image-container { width: 'fill_container', height: 200,
                         layout: 'vertical' }
    ├─ hero-image       { width: 'fill_container', height: 200 }
    ├─ hero-overlay     { width: 'fill_container', height: 200 }  // gradient
    └─ hero-content     { width: 'fill_container', height: 'fit_content' }
        ├─ "Hungry?" title
        └─ search-bar (48 tall)

The model intended the image + overlay to LAYER on top of each
other as bg+gradient with content floating on top. With
\`layout: 'vertical'\` the layout engine instead stacked them
sequentially: 200 + 200 + ~80 = 480, far past the 200 declared
height. No clipContent on the container, so the overflow rendered
into the NEXT sibling section — the user's screenshot showed
"Hungry?" search and category icons piled over the "Near You"
restaurant cards.

\`convertStackedOverlayToAbsolute\` post-pass detects the pattern
conservatively:
  - frame, layout='vertical' (or undefined → infers vertical)
  - numeric fixed height H
  - >= 2 children of types image / rectangle / frame whose height
    is exactly H or 'fill_container'
The repair: switch \`layout\` to 'none' so the layout engine
respects each child's own x/y (defaulting to 0/0 = layered) — the
image lands at (0,0), the overlay layers on top, and the content
frame floats on top. Children with explicit positions stay
respected.

Wired into \`design-canvas-ops.ts::applyPostStreamingTreeHeuristics\`
right after \`expandOverflowingFixedHeightCards\` so both layered
and overflowing-fixed-height fixes run together.

6 tests cover: hero pattern conversion, fill_container variant,
plain content stacks left alone (only one bg-like child), no
fixed height left alone, horizontal-layout side-by-side rows
left alone, nested heroes detected.
This commit is contained in:
Fini 2026-05-05 12:23:30 +08:00
parent acb5000b76
commit b810d4eaed
5 changed files with 265 additions and 0 deletions

View file

@ -17,5 +17,6 @@ export {
stripRedundantSectionFills,
injectMissingNavSurfaceFill,
expandOverflowingFixedHeightCards,
convertStackedOverlayToAbsolute,
normalizeStrokeFillSchema,
} from '@zseven-w/pen-core';

View file

@ -20,6 +20,7 @@ import {
stripRedundantSectionFills,
injectMissingNavSurfaceFill,
expandOverflowingFixedHeightCards,
convertStackedOverlayToAbsolute,
normalizeStrokeFillSchema,
} from '@/canvas/canvas-layout-engine';
import { forcePageResync } from '@/canvas/canvas-sync-utils';
@ -733,6 +734,13 @@ export function applyPostStreamingTreeHeuristics(rootNodeId: string): void {
// preserves both the rounded-image clip behavior and the
// overflowing button.
expandOverflowingFixedHeightCards(pageRoot);
// Detect "layered hero" patterns the model emitted with vertical
// layout — image + overlay + content stacked sequentially when the
// intent was clearly to layer them. Fixes the M2.7 food-app run
// where the hero's overflowing content piled into the next
// section. See packages/pen-core/src/layout/convert-stacked-
// overlay-to-absolute.ts for the conservative pattern detector.
convertStackedOverlayToAbsolute(pageRoot);
// Publish point. unwrap, resolveTreeRoles, and normalizeTreeLayout all
// mutate store-owned nodes in place; resolveTreePostPass mostly goes

View file

@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import type { PenNode } from '@zseven-w/pen-types';
import { convertStackedOverlayToAbsolute } from '../layout/convert-stacked-overlay-to-absolute';
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
({
id: 'f',
type: 'frame',
...props,
}) as PenNode;
const image = (props: Partial<PenNode>): PenNode =>
({
id: 'img',
type: 'image',
...props,
}) as PenNode;
const rect = (props: Partial<PenNode>): PenNode =>
({
id: 'rct',
type: 'rectangle',
...props,
}) as PenNode;
const text = (props: Partial<PenNode>): PenNode =>
({
id: 't',
type: 'text',
...props,
}) as PenNode;
describe('convertStackedOverlayToAbsolute', () => {
it("flips a hero with image+overlay+content from layout='vertical' to 'none'", () => {
// Real M2.7 food-app shape: 200px tall hero, layout=vertical,
// 3 stacked children — bg image, gradient overlay, content.
// Sequential stacking overflows the 200 height; switching to
// layout='none' lets each child sit at its own (default 0,0)
// origin, layering them as the model intended.
const hero = frame({
id: 'hero',
width: 'fill_container',
height: 200,
layout: 'vertical',
children: [
image({ id: 'bg', width: 'fill_container', height: 200 }),
rect({ id: 'overlay', width: 'fill_container', height: 200 }),
frame({
id: 'content',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
children: [
text({ id: 'title', content: 'Hungry?' }),
frame({ id: 'cta', width: 'fit_content', height: 48 }),
],
}),
],
});
const root = frame({ id: 'root', children: [hero] });
const changed = convertStackedOverlayToAbsolute(root);
expect(changed).toBe(true);
expect((hero as PenNode & { layout?: string }).layout).toBe('none');
});
it('matches when one of the bg-likes uses fill_container instead of a numeric match', () => {
const hero = frame({
id: 'hero2',
width: 'fill_container',
height: 220,
layout: 'vertical',
children: [
image({ id: 'bg', width: 'fill_container', height: 220 }),
rect({ id: 'overlay', width: 'fill_container', height: 'fill_container' }),
text({ id: 'caption', content: 'Layered above bg' }),
],
});
const root = frame({ id: 'root', children: [hero] });
const changed = convertStackedOverlayToAbsolute(root);
expect(changed).toBe(true);
expect((hero as PenNode & { layout?: string }).layout).toBe('none');
});
it("doesn't touch normal vertical stacks (only one bg-like child)", () => {
// Plain content section: section header + body text + button.
// Only one full-height child (or none). Must not get re-laid-out
// — converting to absolute would un-stack the content.
const section = frame({
id: 'section',
width: 'fill_container',
height: 300,
layout: 'vertical',
children: [
text({ id: 'h', content: 'Heading' }),
text({ id: 'b', content: 'Body text wraps to multiple lines' }),
frame({ id: 'cta', width: 200, height: 44 }),
],
});
const root = frame({ id: 'root', children: [section] });
const changed = convertStackedOverlayToAbsolute(root);
expect(changed).toBe(false);
expect((section as PenNode & { layout?: string }).layout).toBe('vertical');
});
it("doesn't touch non-fixed-height containers (no overflow risk in fit_content)", () => {
// fit_content / fill_container containers don't risk the stacked
// overflow regression — the parent grows to fit children. Skip
// them so we don't accidentally repair non-bug shapes.
const card = frame({
id: 'card',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
children: [
image({ id: 'bg', width: 'fill_container', height: 200 }),
rect({ id: 'overlay', width: 'fill_container', height: 200 }),
],
});
const root = frame({ id: 'root', children: [card] });
const changed = convertStackedOverlayToAbsolute(root);
expect(changed).toBe(false);
expect((card as PenNode & { layout?: string }).layout).toBe('vertical');
});
it('respects an explicit layout=horizontal (not the bug shape)', () => {
// A horizontal-layout container with side-by-side image+overlay
// is NOT the layered-hero pattern — the model likely meant a
// 2-column row. Don't reach into horizontal layouts.
const row = frame({
id: 'row',
width: 'fill_container',
height: 200,
layout: 'horizontal',
children: [
image({ id: 'left', width: 'fill_container', height: 200 }),
rect({ id: 'right', width: 'fill_container', height: 200 }),
],
});
const root = frame({ id: 'root', children: [row] });
const changed = convertStackedOverlayToAbsolute(root);
expect(changed).toBe(false);
expect((row as PenNode & { layout?: string }).layout).toBe('horizontal');
});
it('walks nested heroes (nested-section regressions)', () => {
const innerHero = frame({
id: 'nested-hero',
width: 'fill_container',
height: 180,
layout: 'vertical',
children: [
image({ id: 'bg', width: 'fill_container', height: 180 }),
rect({ id: 'overlay', width: 'fill_container', height: 180 }),
text({ id: 'cap', content: 'On top' }),
],
});
const wrapper = frame({
id: 'wrap',
role: 'section',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
children: [innerHero],
});
const root = frame({ id: 'root', children: [wrapper] });
const changed = convertStackedOverlayToAbsolute(root);
expect(changed).toBe(true);
expect((innerHero as PenNode & { layout?: string }).layout).toBe('none');
// The wrapper itself was a fit_content section — left alone.
expect((wrapper as PenNode & { layout?: string }).layout).toBe('vertical');
});
});

View file

@ -75,6 +75,7 @@ export { unwrapFakePhoneMockups } from './layout/unwrap-fake-phone-mockup.js';
export { stripRedundantSectionFills } from './layout/strip-redundant-section-fills.js';
export { injectMissingNavSurfaceFill } from './layout/inject-nav-surface-fill.js';
export { expandOverflowingFixedHeightCards } from './layout/expand-overflowing-fixed-height-cards.js';
export { convertStackedOverlayToAbsolute } from './layout/convert-stacked-overlay-to-absolute.js';
export { normalizeStrokeFillSchema } from './normalize/normalize-stroke-fill-schema.js';
// Text measurement

View file

@ -0,0 +1,83 @@
import type { PenNode } from '@zseven-w/pen-types';
/**
* Recursively walk the tree and switch any container that's clearly a
* "layered hero / banner with overlay" currently shipped by the
* model with `layout: 'vertical'` to `layout: 'none'` so its
* children stack on top of each other instead of in sequence.
*
* Why this exists: weak models (MiniMax M2.7 observed) emit hero
* sections like
* frame { layout: 'vertical', height: 200, children: [
* image { width: 'fill_container', height: 200 }, // full-bg photo
* rect { width: 'fill_container', height: 200 }, // gradient overlay
* frame { ...content with title + cta on top }
* ]}
* intending the image and rectangle to LAYER on top of each other
* as the background+gradient, with the content frame floating on
* top. With `layout: 'vertical'` the layout engine instead stacks
* them in sequence: 200 (image) + 200 (overlay) + content_h 500+,
* overflowing the 200 container; combined with no `clipContent`
* the overflow renders into the NEXT sibling section. The food-app
* M2.7 run shipped a hero whose overflow drew the "Hungry?" search
* pile-up over the "Near You" restaurant cards sections visibly
* collided.
*
* Pattern detection (conservative false positives are worse than
* misses):
* - The frame has `layout: 'vertical'` (or undefined, which
* `inferLayout` would also resolve to vertical).
* - The frame has a NUMERIC fixed height `H`.
* - At least 2 of the children are visually-large background-
* candidate types (`image`, `rectangle`, or `frame`) AND have
* `height` exactly equal to `H` (or `'fill_container'`). That's
* the load-bearing signal: nobody emits two same-height bg
* siblings inside a vertical stack on purpose.
*
* Repair: switch the container's `layout` to `'none'`. The layout
* engine then respects each child's own `x` / `y` (defaulting to
* 0/0 when missing), which is exactly the layered intent image
* at (0,0), overlay at (0,0), content at (0,0) on top.
*
* Returns true if any container was patched.
*/
export function convertStackedOverlayToAbsolute(rootFrame: PenNode): boolean {
let changed = false;
const walk = (node: PenNode): void => {
if (node.type === 'frame') {
const c = node as PenNode & {
layout?: string;
height?: unknown;
children?: PenNode[];
};
if ((c.layout === 'vertical' || c.layout === undefined) && typeof c.height === 'number') {
const containerH = c.height;
const children = Array.isArray(c.children) ? c.children : [];
let bgLike = 0;
for (const child of children) {
if (child.type !== 'image' && child.type !== 'rectangle' && child.type !== 'frame') {
continue;
}
const childH = (child as PenNode & { height?: unknown }).height;
if (
(typeof childH === 'number' && childH === containerH) ||
childH === 'fill_container'
) {
bgLike += 1;
}
}
if (bgLike >= 2) {
c.layout = 'none';
changed = true;
}
}
}
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) walk(child);
}
};
walk(rootFrame);
return changed;
}