From f8f7d0e1ca92f82b7b92995bedddf0fde314314c Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 5 May 2026 22:00:00 +0800 Subject: [PATCH] fix(pen-core): convert stacked-overlay heroes to layout=none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/canvas/canvas-layout-engine.ts | 1 + apps/web/src/services/ai/design-canvas-ops.ts | 8 + ...onvert-stacked-overlay-to-absolute.test.ts | 172 ++++++++++++++++++ packages/pen-core/src/index.ts | 1 + .../convert-stacked-overlay-to-absolute.ts | 83 +++++++++ 5 files changed, 265 insertions(+) create mode 100644 packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts create mode 100644 packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts diff --git a/apps/web/src/canvas/canvas-layout-engine.ts b/apps/web/src/canvas/canvas-layout-engine.ts index 8058f1fe5..8b547980e 100644 --- a/apps/web/src/canvas/canvas-layout-engine.ts +++ b/apps/web/src/canvas/canvas-layout-engine.ts @@ -17,5 +17,6 @@ export { stripRedundantSectionFills, injectMissingNavSurfaceFill, expandOverflowingFixedHeightCards, + convertStackedOverlayToAbsolute, normalizeStrokeFillSchema, } from '@zseven-w/pen-core'; diff --git a/apps/web/src/services/ai/design-canvas-ops.ts b/apps/web/src/services/ai/design-canvas-ops.ts index abcc8053a..199f23e47 100644 --- a/apps/web/src/services/ai/design-canvas-ops.ts +++ b/apps/web/src/services/ai/design-canvas-ops.ts @@ -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 diff --git a/packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts b/packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts new file mode 100644 index 000000000..ecc7a3785 --- /dev/null +++ b/packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts @@ -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 & { children?: PenNode[] }): PenNode => + ({ + id: 'f', + type: 'frame', + ...props, + }) as PenNode; + +const image = (props: Partial): PenNode => + ({ + id: 'img', + type: 'image', + ...props, + }) as PenNode; + +const rect = (props: Partial): PenNode => + ({ + id: 'rct', + type: 'rectangle', + ...props, + }) as PenNode; + +const text = (props: Partial): 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'); + }); +}); diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 6fdfe6bba..ee286e87f 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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 diff --git a/packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts b/packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts new file mode 100644 index 000000000..afb32fe36 --- /dev/null +++ b/packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts @@ -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; +}