mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
acb5000b76
commit
b810d4eaed
5 changed files with 265 additions and 0 deletions
|
|
@ -17,5 +17,6 @@ export {
|
|||
stripRedundantSectionFills,
|
||||
injectMissingNavSurfaceFill,
|
||||
expandOverflowingFixedHeightCards,
|
||||
convertStackedOverlayToAbsolute,
|
||||
normalizeStrokeFillSchema,
|
||||
} from '@zseven-w/pen-core';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue