openpencil/packages/pen-core/src/layout/normalize-tree.ts
Fini 1be9ec4a19 fix(canvas): require explicit role:'overlay' for layout-flow escape hatch
isBadgeOverlayNode matched role:'badge'|'pill'|'tag' and pulled those
children out of their parent's auto-layout, rendering them at (0,0) of
the parent and stacking them on top of siblings. But in this repo
badge/pill/tag are inline-component roles (see role-resolver NAME_EXACT_MAP
and strip-redundant-section-fills PROTECTED_ROLES) — they're meant to
flow in layout like any other child.

Rename to isOverlayNode and narrow to role:'overlay'. Add matching
"Layout-escape roles" guidance in role-definitions.md so generation
prompts can reach the new opt-in. Inline roles now flow correctly;
true floating decorations (notification dots, corner ribbons) still
have a dedicated marker.
2026-04-16 21:07:40 +08:00

141 lines
6.1 KiB
TypeScript

import type { PenNode, ContainerProps } from '@zseven-w/pen-types';
import { isOverlayNode } from '../node-helpers.js';
import { inferLayout } from './engine.js';
/**
* Normalize layout state across a node tree (mutates in place).
*
* Two fixes applied recursively to every frame:
*
* 1. When a frame has children but no explicit `layout`, write one:
* - First try `inferLayout()` (horizontal signals: gap, padding,
* fill_container children).
* - If that returns undefined and the frame has 2+ children, fall back
* to `vertical` — BUT only when no non-overlay child carries explicit
* `x`/`y`. Any such coordinate is treated as a deliberate signal that
* the frame is an absolute-positioning container (phone mockups, hero
* images with floating overlays, etc.) and we leave it alone.
*
* 2. When a frame has an active layout (`vertical` or `horizontal`), strip
* `x`/`y` from every non-overlay child. Overlay children (opt-in via
* `role: 'overlay'`) keep their absolute coordinates.
*
* Used as a post-generation pass after an AI model produces a subtree. It
* corrects two common model mistakes:
* - Forgetting to set `layout` on a container (children would otherwise
* stack at (0,0) because `computeLayoutPositions` skips layout-less
* parents).
* - Leaving stale `x`/`y` on children of an auto-layout frame (causes
* visible misalignment when the layout engine also tries to position
* them).
*
* Ordering requirement: MUST run AFTER any role-based resolution pass that
* populates `layout` from semantic roles (e.g. navbar → 'horizontal').
* Running this first would write the generic 'vertical' fallback onto a
* navbar frame, and the later role resolver — which only fills undefined
* fields — would refuse to overwrite it. Treat this function as the last
* safety net, not the first opinion.
*/
export function normalizeTreeLayout(node: PenNode): void {
if (node.type === 'frame' && 'children' in node && Array.isArray(node.children)) {
const c = node as PenNode & ContainerProps;
const children = node.children;
// (1) Ensure an explicit layout when children exist.
// Only fill in when `layout` is missing — an explicit `'none'` is
// intentional (absolute positioning) and must be preserved.
if (c.layout == null && children.length > 0) {
const inferred = inferLayout(node);
if (inferred) {
c.layout = inferred;
} else if (children.length >= 2 && !hasAbsolutePositionedChild(children)) {
// Safe to treat as a "model forgot layout" case: nobody carries x/y,
// so there's no absolute-positioning intent to destroy.
c.layout = 'vertical';
}
}
// (2) Strip x/y from non-overlay children of active-layout frames.
if (c.layout === 'vertical' || c.layout === 'horizontal') {
for (const child of children) {
if (!isOverlayNode(child)) {
if ('x' in child) delete (child as { x?: number }).x;
if ('y' in child) delete (child as { y?: number }).y;
}
}
}
}
// Recurse into children regardless of node type (groups/pages may nest frames).
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
normalizeTreeLayout(child);
}
}
}
/**
* Decide whether a layout-less parent should be treated as an absolute-
* positioning container (skip the `vertical` fallback) instead of being
* verticalized.
*
* Two signals count as "this is an absolute-positioning container":
*
* 1. A non-overlay child has a numeric `x` or `y` (including 0). Models
* that explicitly position children clearly intend absolute layout.
*
* 2. EVERY non-overlay child is a "composition primitive" — currently
* `frame`, `ellipse`, or `path`. AI models routinely emit structured
* compositions at (0,0) without writing x/y at all: nested-frame
* badge stacks, hero overlays, rings built from concentric
* ellipses, icon compositions built from paths. The previous check
* only counted frame children, so an ellipse-only or path-only
* composition (e.g. 3 concentric ellipses forming a progress ring)
* fell through to the vertical fallback and rendered as a broken
* list.
*
* The rule intentionally does NOT treat `rectangle` as a
* composition primitive: plain rectangles are by far the most
* common children in content stacks (nav buttons, list items,
* card backgrounds) and defaulting those to overlay would silently
* break many more designs than it fixes. When you really want a
* rectangle overlay composition, declare x/y explicitly.
*
* Mixed content stacks (frame + text, shape + icon_font, etc.)
* still get the vertical fallback, because those are typically
* content stacks where verticalization is the right call.
*
* Overlay nodes (opt-in via `role: 'overlay'`, detected by `isOverlayNode`)
* are excluded from the count — they legitimately carry x/y inside
* auto-layout frames and shouldn't tip the heuristic.
*
* This is intentionally conservative: it accepts a few false negatives
* (some genuinely vertical all-shape stacks will be left un-normalized,
* forcing the AI to declare layout explicitly) to eliminate the
* catastrophic false-positive of silently verticalizing an overlay
* composition.
*/
const COMPOSITION_PRIMITIVE_TYPES = new Set(['frame', 'ellipse', 'path']);
function hasAbsolutePositionedChild(children: PenNode[]): boolean {
// Signal 1: explicit numeric x/y on any non-overlay child.
for (const child of children) {
if (isOverlayNode(child)) continue;
const c = child as PenNode & { x?: number; y?: number };
if (typeof c.x === 'number' || typeof c.y === 'number') return true;
}
// Signal 2: every non-overlay child is a composition primitive
// (>= 2 such children).
let nonOverlayCount = 0;
let primitiveCount = 0;
for (const child of children) {
if (isOverlayNode(child)) continue;
nonOverlayCount++;
if (COMPOSITION_PRIMITIVE_TYPES.has(child.type)) primitiveCount++;
}
if (nonOverlayCount >= 2 && primitiveCount === nonOverlayCount) return true;
return false;
}