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.
141 lines
6.1 KiB
TypeScript
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;
|
|
}
|