Fix layout axis mismatch, reduce describe noise, add bail-out strategy

Engine:
- layoutSizingVertical/Horizontal setters now use child's own layout
  direction (not parent's) to map primary/counter axis — matches Yoga
- FILL on cross-axis uses alignSelf:Stretch instead of flexGrow
  (flexGrow only works on main axis, caused even distribution bug)
- FILL on main axis auto-sets layoutGrow=1 for leaf nodes

This fixes the 20-step debugging spiral where AI set FIXED heights
on HORIZONTAL children inside VERTICAL parent but Yoga ignored them
(wrote primaryAxisSizing via parent axis, Yoga read counterAxisSizing
via child axis — never saw the FIXED value).

Describe noise reduction:
- Suppress 'absolutely positioned inside flex' — intentional for
  decorative overlays (selection handles, badges)
- Suppress 'justify=between with HUG' when node fills parent width
- Suppress 'fixed size + grow' when sizing is FILL (standard pattern)
- 'Inconsistent padding' requires 3+ siblings (not 2)

Prompt:
- Bail-out: delete and re-render after 2 failed set_* attempts
- eval restricted to operations, not layout debugging
This commit is contained in:
Anton A S 2026-03-12 23:01:36 +03:00
parent 19bef49d4e
commit d46b4b11a4
9 changed files with 1049 additions and 32 deletions

View file

@ -39,7 +39,923 @@ export function computeImageHash(data: Uint8Array): string {
return [h1, h2, h3, h4, h5].map(h => h.toString(16).padStart(8, '0')).join('')
}
export class FigmaAPI implements NodeProxyHost {
const INTERNAL_ID = Symbol('id')
const INTERNAL_GRAPH = Symbol('graph')
const INTERNAL_API = Symbol('api')
class FigmaNodeProxy {
[INTERNAL_ID]: string;
[INTERNAL_GRAPH]: SceneGraph;
[INTERNAL_API]: FigmaAPI
constructor(id: string, graph: SceneGraph, api: FigmaAPI) {
this[INTERNAL_ID] = id
this[INTERNAL_GRAPH] = graph
this[INTERNAL_API] = api
}
private _raw(): SceneNode {
const n = this[INTERNAL_GRAPH].getNode(this[INTERNAL_ID])
if (!n) throw new Error(`Node ${this[INTERNAL_ID]} has been removed`)
return n
}
private _parentLayout(): 'HORIZONTAL' | 'VERTICAL' | 'NONE' {
const n = this._raw()
if (!n.parentId) return 'NONE'
const parent = this[INTERNAL_GRAPH].getNode(n.parentId)
if (!parent) return 'NONE'
const mode = parent.layoutMode
return mode === 'HORIZONTAL' || mode === 'VERTICAL' ? mode : 'NONE'
}
get id(): string {
return this[INTERNAL_ID]
}
get type(): NodeType {
return this._raw().type
}
get name(): string {
return this._raw().name
}
set name(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { name: v })
}
get removed(): boolean {
return !this[INTERNAL_GRAPH].getNode(this[INTERNAL_ID])
}
// --- Geometry ---
get x(): number {
return this._raw().x
}
set x(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { x: v })
}
get y(): number {
return this._raw().y
}
set y(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { y: v })
}
get width(): number {
return this._raw().width
}
get height(): number {
return this._raw().height
}
get rotation(): number {
return this._raw().rotation
}
set rotation(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { rotation: v })
}
resize(width: number, height: number): void {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { width, height })
}
resizeWithoutConstraints(width: number, height: number): void {
this.resize(width, height)
}
get absoluteTransform(): [[number, number, number], [number, number, number]] {
const pos = this[INTERNAL_GRAPH].getAbsolutePosition(this[INTERNAL_ID])
return [
[1, 0, pos.x],
[0, 1, pos.y]
]
}
get absoluteBoundingBox(): Rect {
return this[INTERNAL_GRAPH].getAbsoluteBounds(this[INTERNAL_ID])
}
get absoluteRenderBounds(): Rect {
return this.absoluteBoundingBox
}
// --- Visual ---
get fills(): readonly Fill[] {
return Object.freeze(copyFills(this._raw().fills))
}
set fills(v: readonly Fill[]) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fills: [...v] })
}
get strokes(): readonly Stroke[] {
return Object.freeze(copyStrokes(this._raw().strokes))
}
set strokes(v: readonly Stroke[]) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes: [...v] })
}
get effects(): readonly Effect[] {
return Object.freeze(copyEffects(this._raw().effects))
}
set effects(v: readonly Effect[]) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { effects: [...v] })
}
get opacity(): number {
return this._raw().opacity
}
set opacity(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { opacity: v })
}
get visible(): boolean {
return this._raw().visible
}
set visible(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { visible: v })
}
get locked(): boolean {
return this._raw().locked
}
set locked(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { locked: v })
}
get blendMode(): string {
return this._raw().blendMode
}
set blendMode(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { blendMode: v as SceneNode['blendMode'] })
}
get clipsContent(): boolean {
return this._raw().clipsContent
}
set clipsContent(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { clipsContent: v })
}
// --- Corner Radius ---
get cornerRadius(): number | typeof MIXED {
const n = this._raw()
if (n.independentCorners) return MIXED
return n.cornerRadius
}
set cornerRadius(v: number | typeof MIXED) {
if (v === MIXED) return
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
cornerRadius: v,
topLeftRadius: v,
topRightRadius: v,
bottomRightRadius: v,
bottomLeftRadius: v,
independentCorners: false
})
}
get topLeftRadius(): number {
return this._raw().topLeftRadius
}
set topLeftRadius(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
topLeftRadius: v,
independentCorners: true
})
}
get topRightRadius(): number {
return this._raw().topRightRadius
}
set topRightRadius(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
topRightRadius: v,
independentCorners: true
})
}
get bottomLeftRadius(): number {
return this._raw().bottomLeftRadius
}
set bottomLeftRadius(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
bottomLeftRadius: v,
independentCorners: true
})
}
get bottomRightRadius(): number {
return this._raw().bottomRightRadius
}
set bottomRightRadius(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
bottomRightRadius: v,
independentCorners: true
})
}
get cornerSmoothing(): number {
return this._raw().cornerSmoothing
}
set cornerSmoothing(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { cornerSmoothing: v })
}
// --- Stroke details ---
get strokeWeight(): number {
const s = this._raw().strokes
return s.length > 0 ? s[0].weight : 0
}
set strokeWeight(v: number) {
const n = this._raw()
if (n.strokes.length > 0) {
const strokes = copyStrokes(n.strokes)
strokes[0].weight = v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes })
}
}
get strokeAlign(): string {
const s = this._raw().strokes
return s.length > 0 ? s[0].align : 'INSIDE'
}
set strokeAlign(v: string) {
const n = this._raw()
if (n.strokes.length > 0) {
const strokes = copyStrokes(n.strokes)
strokes[0].align = v as Stroke['align']
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes })
}
}
get dashPattern(): readonly number[] {
return Object.freeze([...this._raw().dashPattern])
}
set dashPattern(v: readonly number[]) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { dashPattern: [...v] })
}
get strokeCap(): string {
return this._raw().strokeCap
}
set strokeCap(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeCap: v as SceneNode['strokeCap'] })
}
get strokeJoin(): string {
return this._raw().strokeJoin
}
set strokeJoin(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeJoin: v as SceneNode['strokeJoin'] })
}
get strokeMiterLimit(): number {
return this._raw().strokeMiterLimit
}
set strokeMiterLimit(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeMiterLimit: v })
}
get strokeTopWeight(): number {
return this._raw().borderTopWeight
}
set strokeTopWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
borderTopWeight: v,
independentStrokeWeights: true
})
}
get strokeBottomWeight(): number {
return this._raw().borderBottomWeight
}
set strokeBottomWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
borderBottomWeight: v,
independentStrokeWeights: true
})
}
get strokeLeftWeight(): number {
return this._raw().borderLeftWeight
}
set strokeLeftWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
borderLeftWeight: v,
independentStrokeWeights: true
})
}
get strokeRightWeight(): number {
return this._raw().borderRightWeight
}
set strokeRightWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
borderRightWeight: v,
independentStrokeWeights: true
})
}
// --- Text ---
get characters(): string {
return this._raw().text
}
set characters(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text: v })
}
get fontSize(): number {
return this._raw().fontSize
}
set fontSize(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fontSize: v })
}
get fontName(): FigmaFontName {
const n = this._raw()
return { family: n.fontFamily, style: weightToStyleName(n.fontWeight, n.italic) }
}
set fontName(v: FigmaFontName) {
const { weight, italic } = styleNameToWeight(v.style)
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
fontFamily: v.family,
fontWeight: weight,
italic
})
}
get fontWeight(): number {
return this._raw().fontWeight
}
set fontWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fontWeight: v })
}
get textAlignHorizontal(): string {
return this._raw().textAlignHorizontal
}
set textAlignHorizontal(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
textAlignHorizontal: v as SceneNode['textAlignHorizontal']
})
}
get textAlignVertical(): string {
return this._raw().textAlignVertical
}
set textAlignVertical(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
textAlignVertical: v as SceneNode['textAlignVertical']
})
}
get textAutoResize(): string {
return this._raw().textAutoResize
}
set textAutoResize(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
textAutoResize: v as SceneNode['textAutoResize']
})
}
get letterSpacing(): number {
return this._raw().letterSpacing
}
set letterSpacing(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { letterSpacing: v })
}
get lineHeight(): number | null {
return this._raw().lineHeight
}
set lineHeight(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { lineHeight: v })
}
get textCase(): string {
return this._raw().textCase
}
set textCase(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { textCase: v as SceneNode['textCase'] })
}
get textDecoration(): string {
return this._raw().textDecoration
}
set textDecoration(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
textDecoration: v as SceneNode['textDecoration']
})
}
get maxLines(): number | null {
return this._raw().maxLines
}
set maxLines(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxLines: v })
}
get textTruncation(): string {
return this._raw().textTruncation
}
set textTruncation(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
textTruncation: v as SceneNode['textTruncation']
})
}
get autoRename(): boolean {
return this._raw().autoRename
}
set autoRename(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { autoRename: v })
}
insertCharacters(start: number, characters: string): void {
const n = this._raw()
const text = n.text.slice(0, start) + characters + n.text.slice(start)
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text })
}
deleteCharacters(start: number, end: number): void {
const n = this._raw()
const text = n.text.slice(0, start) + n.text.slice(end)
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text })
}
// --- Auto-layout ---
get layoutMode(): LayoutMode {
return this._raw().layoutMode
}
set layoutMode(v: LayoutMode) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutMode: v })
}
get primaryAxisAlignItems(): string {
return this._raw().primaryAxisAlign
}
set primaryAxisAlignItems(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
primaryAxisAlign: v as SceneNode['primaryAxisAlign']
})
}
get counterAxisAlignItems(): string {
return this._raw().counterAxisAlign
}
set counterAxisAlignItems(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
counterAxisAlign: v as SceneNode['counterAxisAlign']
})
}
get itemSpacing(): number {
return this._raw().itemSpacing
}
set itemSpacing(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemSpacing: v })
}
get counterAxisSpacing(): number {
return this._raw().counterAxisSpacing
}
set counterAxisSpacing(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisSpacing: v })
}
get paddingTop(): number {
return this._raw().paddingTop
}
set paddingTop(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingTop: v })
}
get paddingRight(): number {
return this._raw().paddingRight
}
set paddingRight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingRight: v })
}
get paddingBottom(): number {
return this._raw().paddingBottom
}
set paddingBottom(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingBottom: v })
}
get paddingLeft(): number {
return this._raw().paddingLeft
}
set paddingLeft(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { paddingLeft: v })
}
get layoutWrap(): string {
return this._raw().layoutWrap
}
set layoutWrap(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutWrap: v as SceneNode['layoutWrap'] })
}
get primaryAxisSizingMode(): string {
return this._raw().primaryAxisSizing === 'HUG' ? 'AUTO' : this._raw().primaryAxisSizing
}
set primaryAxisSizingMode(v: string) {
const mapped = v === 'AUTO' ? 'HUG' : v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
primaryAxisSizing: mapped as SceneNode['primaryAxisSizing']
})
}
get counterAxisSizingMode(): string {
return this._raw().counterAxisSizing === 'HUG' ? 'AUTO' : this._raw().counterAxisSizing
}
set counterAxisSizingMode(v: string) {
const mapped = v === 'AUTO' ? 'HUG' : v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
counterAxisSizing: mapped as SceneNode['counterAxisSizing']
})
}
get counterAxisAlignContent(): string {
return this._raw().counterAxisAlignContent
}
set counterAxisAlignContent(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
counterAxisAlignContent: v as SceneNode['counterAxisAlignContent']
})
}
get itemReverseZIndex(): boolean {
return this._raw().itemReverseZIndex
}
set itemReverseZIndex(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemReverseZIndex: v })
}
get strokesIncludedInLayout(): boolean {
return this._raw().strokesIncludedInLayout
}
set strokesIncludedInLayout(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokesIncludedInLayout: v })
}
// --- Layout child props ---
get layoutPositioning(): string {
return this._raw().layoutPositioning
}
set layoutPositioning(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
layoutPositioning: v as SceneNode['layoutPositioning']
})
}
get layoutGrow(): number {
return this._raw().layoutGrow
}
set layoutGrow(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutGrow: v })
}
get layoutAlign(): string {
const n = this._raw()
if (n.layoutAlignSelf === 'AUTO') return 'INHERIT'
return n.layoutAlignSelf
}
set layoutAlign(v: string) {
const mapped = v === 'INHERIT' ? 'AUTO' : v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
layoutAlignSelf: mapped as SceneNode['layoutAlignSelf']
})
}
get layoutSizingHorizontal(): string {
const n = this._raw()
const layout = n.layoutMode !== 'NONE' ? n.layoutMode : this._parentLayout()
if (layout === 'NONE') return 'FIXED'
return layout === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing
}
set layoutSizingHorizontal(v: string) {
const n = this._raw()
const layout = n.layoutMode !== 'NONE' ? n.layoutMode : this._parentLayout()
const parentLayout = this._parentLayout()
const isMainAxis = parentLayout === 'HORIZONTAL'
const updates: Partial<SceneNode> = layout === 'VERTICAL'
? { counterAxisSizing: v as SceneNode['counterAxisSizing'] }
: { primaryAxisSizing: v as SceneNode['primaryAxisSizing'] }
if (isMainAxis) updates.layoutGrow = v === 'FILL' ? 1 : 0
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], updates)
}
get layoutSizingVertical(): string {
const n = this._raw()
const layout = n.layoutMode !== 'NONE' ? n.layoutMode : this._parentLayout()
if (layout === 'NONE') return 'FIXED'
return layout === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing
}
set layoutSizingVertical(v: string) {
const n = this._raw()
const layout = n.layoutMode !== 'NONE' ? n.layoutMode : this._parentLayout()
const parentLayout = this._parentLayout()
const isMainAxis = parentLayout === 'VERTICAL'
const updates: Partial<SceneNode> = layout === 'HORIZONTAL'
? { counterAxisSizing: v as SceneNode['counterAxisSizing'] }
: { primaryAxisSizing: v as SceneNode['primaryAxisSizing'] }
if (isMainAxis) updates.layoutGrow = v === 'FILL' ? 1 : 0
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], updates)
}
// --- Constraints ---
get constraints(): { horizontal: string; vertical: string } {
const n = this._raw()
return { horizontal: n.horizontalConstraint, vertical: n.verticalConstraint }
}
set constraints(v: { horizontal: string; vertical: string }) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
horizontalConstraint: v.horizontal as SceneNode['horizontalConstraint'],
verticalConstraint: v.vertical as SceneNode['verticalConstraint']
})
}
// --- Dimension constraints ---
get minWidth(): number | null {
return this._raw().minWidth
}
set minWidth(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minWidth: v })
}
get maxWidth(): number | null {
return this._raw().maxWidth
}
set maxWidth(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxWidth: v })
}
get minHeight(): number | null {
return this._raw().minHeight
}
set minHeight(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minHeight: v })
}
get maxHeight(): number | null {
return this._raw().maxHeight
}
set maxHeight(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxHeight: v })
}
// --- Mask ---
get isMask(): boolean {
return this._raw().isMask
}
set isMask(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { isMask: v })
}
get maskType(): string {
return this._raw().maskType
}
set maskType(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maskType: v as SceneNode['maskType'] })
}
// --- UI state ---
get expanded(): boolean {
return this._raw().expanded
}
set expanded(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { expanded: v })
}
// --- Components ---
get mainComponent(): FigmaNodeProxy | null {
const n = this._raw()
if (!n.componentId) return null
const comp = this[INTERNAL_GRAPH].getNode(n.componentId)
if (!comp) return null
return this[INTERNAL_API].wrapNode(comp.id)
}
createInstance(): FigmaNodeProxy {
const n = this._raw()
if (n.type !== 'COMPONENT') throw new Error('createInstance() can only be called on components')
const pageId = this[INTERNAL_API].currentPageId
const inst = this[INTERNAL_GRAPH].createInstance(n.id, pageId)
if (!inst) throw new Error('Failed to create instance')
return this[INTERNAL_API].wrapNode(inst.id)
}
// --- Tree ---
get parent(): FigmaNodeProxy | null {
const n = this._raw()
if (!n.parentId) return null
return this[INTERNAL_API].wrapNode(n.parentId)
}
get children(): FigmaNodeProxy[] {
return this[INTERNAL_GRAPH]
.getChildren(this[INTERNAL_ID])
.map((c) => this[INTERNAL_API].wrapNode(c.id))
}
appendChild(child: FigmaNodeProxy): void {
this[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID])
}
insertChild(index: number, child: FigmaNodeProxy): void {
this[INTERNAL_GRAPH].reparentNode(child[INTERNAL_ID], this[INTERNAL_ID])
this[INTERNAL_GRAPH].reorderChild(child[INTERNAL_ID], this[INTERNAL_ID], index)
}
clone(): FigmaNodeProxy {
const n = this._raw()
const parentId = n.parentId ?? this[INTERNAL_API].currentPageId
const cloned = this[INTERNAL_GRAPH].cloneTree(this[INTERNAL_ID], parentId)
if (!cloned) throw new Error(`Failed to clone node ${this[INTERNAL_ID]}`)
return this[INTERNAL_API].wrapNode(cloned.id)
}
remove(): void {
this[INTERNAL_GRAPH].deleteNode(this[INTERNAL_ID])
}
findAll(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] {
const results: FigmaNodeProxy[] = []
const walk = (id: string) => {
for (const child of this[INTERNAL_GRAPH].getChildren(id)) {
const proxy = this[INTERNAL_API].wrapNode(child.id)
if (!callback || callback(proxy)) results.push(proxy)
walk(child.id)
}
}
walk(this[INTERNAL_ID])
return results
}
findOne(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null {
const walk = (id: string): FigmaNodeProxy | null => {
for (const child of this[INTERNAL_GRAPH].getChildren(id)) {
const proxy = this[INTERNAL_API].wrapNode(child.id)
if (callback(proxy)) return proxy
const found = walk(child.id)
if (found) return found
}
return null
}
return walk(this[INTERNAL_ID])
}
findChild(callback: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy | null {
for (const child of this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID])) {
const proxy = this[INTERNAL_API].wrapNode(child.id)
if (callback(proxy)) return proxy
}
return null
}
findChildren(callback?: (node: FigmaNodeProxy) => boolean): FigmaNodeProxy[] {
return this[INTERNAL_GRAPH]
.getChildren(this[INTERNAL_ID])
.map((c) => this[INTERNAL_API].wrapNode(c.id))
.filter((proxy) => !callback || callback(proxy))
}
findAllWithCriteria(criteria: { types?: string[] }): FigmaNodeProxy[] {
const types = criteria.types ? new Set(criteria.types) : null
return this.findAll((node) => !types || types.has(node.type))
}
// --- Serialization ---
toJSON(): Record<string, unknown> {
const n = this._raw()
const obj: Record<string, unknown> = {
id: n.id,
type: n.type,
name: n.name,
x: n.x,
y: n.y,
width: n.width,
height: n.height
}
if (n.fills.length > 0) obj.fills = n.fills
if (n.strokes.length > 0) obj.strokes = n.strokes
if (n.effects.length > 0) obj.effects = n.effects
if (n.opacity !== 1) obj.opacity = n.opacity
if (n.cornerRadius > 0) obj.cornerRadius = n.cornerRadius
if (!n.visible) obj.visible = false
if (n.text) obj.characters = n.text
if (n.layoutMode !== 'NONE') {
obj.layoutMode = n.layoutMode
obj.itemSpacing = n.itemSpacing
}
const children = this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID])
if (children.length > 0) {
obj.children = children.map((c) => this[INTERNAL_API].wrapNode(c.id).toJSON())
}
return obj
}
toString(): string {
const n = this._raw()
return `[${n.type} "${n.name}" ${n.id}]`
}
[Symbol.for('nodejs.util.inspect.custom')](): string {
return this.toString()
}
}
export class FigmaAPI {
readonly graph: SceneGraph
private _currentPageId: string
private _selection: FigmaNodeProxy[] = []

View file

@ -316,12 +316,14 @@ function configureChildAsAutoLayout(
const widthSizing = isChildRow ? child.primaryAxisSizing : child.counterAxisSizing
const heightSizing = isChildRow ? child.counterAxisSizing : child.primaryAxisSizing
// Main axis: width for row parent, height for col parent — use grow for FILL
// Cross axis: height for row parent, width for col parent — use stretch for FILL
if (isParentRow) {
setSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
setSizing(yogaChild, 'height', heightSizing, child.height, 0)
setMainAxisSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
setCrossAxisSizing(yogaChild, 'height', heightSizing, child.height)
} else {
setSizing(yogaChild, 'width', widthSizing, child.width, 0)
setSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
setCrossAxisSizing(yogaChild, 'width', widthSizing, child.width)
setMainAxisSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
@ -465,7 +467,7 @@ function configureNonTextLeaf(
}
}
function setSizing(
function setMainAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
@ -494,6 +496,25 @@ function setSizing(
}
}
function setCrossAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
fixedValue: number
): void {
switch (sizing) {
case 'FIXED':
if (axis === 'width') yogaNode.setWidth(fixedValue)
else yogaNode.setHeight(fixedValue)
break
case 'HUG':
break
case 'FILL':
yogaNode.setAlignSelf(Align.Stretch)
break
}
}
function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
if (frame.layoutMode === 'GRID') {
if (frame.gridTemplateRows.length === 0) {

View file

@ -274,7 +274,7 @@ function detectSpacingIssues(node: SceneNode, graph: SceneGraph, gridSize: numbe
}
const flexChildren = children.filter((c) => c.layoutMode !== 'NONE')
if (flexChildren.length >= 2) {
if (flexChildren.length >= 3) {
const paddings = flexChildren.map((c) => c.paddingTop + c.paddingRight + c.paddingBottom + c.paddingLeft)
const gaps = flexChildren.map((c) => c.itemSpacing)
if (new Set(paddings).size > 2) {

View file

@ -29,6 +29,14 @@ interface LayoutContext {
issues: DescribeIssue[]
}
function isEffectivelyFilling(node: SceneNode, isRow: boolean, graph: SceneGraph): boolean {
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
if (!parent) return false
const parentDim = isRow ? parent.width : parent.height
const nodeDim = isRow ? node.width : node.height
return parentDim > 0 && Math.abs(nodeDim - parentDim) < 2
}
function checkAlignmentIssues(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
@ -39,7 +47,8 @@ function checkAlignmentIssues(ctx: LayoutContext): void {
})
}
if (node.primaryAxisAlign === 'SPACE_BETWEEN' && node.primaryAxisSizing === 'HUG') {
if (node.primaryAxisAlign === 'SPACE_BETWEEN' && node.primaryAxisSizing === 'HUG'
&& !isEffectivelyFilling(node, isRow, ctx.graph)) {
issues.push({
message: `justify="between" on "${node.name}" with HUG sizing — no effect when parent shrinks to fit`,
suggestion: 'Set a fixed size or use w="fill"'
@ -93,7 +102,7 @@ function checkDividerOrientation(ctx: LayoutContext): void {
}
function checkGrowInHug(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
const { node, children, issues } = ctx
if (node.primaryAxisSizing !== 'HUG') return
for (const child of children) {
if (child.layoutGrow > 0) {
@ -108,6 +117,8 @@ function checkGrowInHug(ctx: LayoutContext): void {
function checkGrowSizeConflict(ctx: LayoutContext): void {
for (const child of ctx.children) {
if (child.layoutGrow > 0 && child.layoutMode === 'NONE') {
const mainSizing = ctx.isRow ? child.primaryAxisSizing : child.counterAxisSizing
if (mainSizing === 'FILL') continue
const fixedDim = ctx.isRow ? child.width : child.height
if (fixedDim > 0 && fixedDim !== 100) {
ctx.issues.push({
@ -271,23 +282,13 @@ function checkFillWithoutFlex(ctx: LayoutContext): void {
}
}
function checkAbsoluteInFlex(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
if (node.layoutMode === 'NONE') return
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible || child.layoutPositioning !== 'ABSOLUTE') continue
if (child.type === 'TEXT' || CONTAINER_TYPES.has(child.type)) {
issues.push({
message: `"${child.name}" is absolutely positioned inside flex "${node.name}" — excluded from layout flow`,
suggestion: 'Remove x/y to return to flex flow, or wrap in a separate absolute container'
})
}
}
function checkAbsoluteInFlex(_ctx: LayoutContext): void {
// Absolute children in flex are intentional (selection handles, decorative overlays).
// Not reported — describe summary already shows "positioned: ABSOLUTE".
}
function checkNestedFlexWithoutFill(ctx: LayoutContext): void {
const { node, isRow, graph, children, issues } = ctx
const { node, isRow, children, issues } = ctx
if (node.layoutMode === 'NONE') return
for (const child of children) {
if (child.layoutMode === 'NONE') continue

View file

@ -248,7 +248,7 @@ export function serializeChatLog(messages: UIMessage[]): string {
parts.push(` ${p.text as string}`)
} else if (p.type === 'reasoning') {
parts.push(
` [reasoning] ${typeof p.text === 'string' ? p.text : (typeof p.content === 'string' ? p.content : '')}`
` [reasoning] ${typeof p.text === 'string' ? p.text : typeof p.content === 'string' ? p.content : ''}`
)
} else if (
p.type === 'tool-invocation' ||

View file

@ -98,13 +98,16 @@ No style={{}}, className, CSS. No named colors or rgb(). No percentage values. N
Typically **3 renders + 3–4 describes**. `describe` the root with `depth=2` — shows sections AND their children in one call.
⚠ **Issues from `describe` are NOT advisory — they are bugs.** Fix every issue before finishing. Common ones:
⚠ **Issues from `describe` are bugs.** Fix every issue before finishing. Common ones:
- "gap N not on 8px grid" → fix the gap
- "grow inside HUG parent" → set parent to fixed size or use h="fill"
- "nested flex may collapse" → add w="fill" or grow
- "duplicate sibling names" → rename
- "near-invisible fill" → increase alpha
⚠ **If a `set_*` fix doesn't work after 2 attempts — delete the node and re-render with corrections. Do NOT debug with `eval`.**
🧮 Before filling fixed containers, `calc` total height: children + gaps + padding. Compare to available space from `describe`.
🚫 Do NOT put everything in one render. Do NOT skip `describe`. Do NOT `describe` individual children when `depth=2` covers them. Do NOT skip the final describe after fixes.
@ -117,4 +120,4 @@ You have **50 steps** per message. Budget: ~3 renders + ~3 describes + fixes = 1
## Advanced tools
For operations not covered by the core set (variables, boolean ops, path editing, analysis, codegen, components, export), use `eval` with `figma` API access. Example: `eval({ code: "return figma.currentPage.children.length" })`.
`eval` is for **operations** not covered by core tools (variables, boolean ops, components, export). Do NOT use eval for debugging layout — delete and re-render instead. Example: `eval({ code: "return figma.currentPage.children.length" })`.

View file

@ -103,11 +103,7 @@ function handleClearChat() {
<ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
<!-- Thinking indicator: shown when AI is working but no visible activity -->
<div
v-if="isThinking"
data-test-id="chat-typing-indicator"
class="flex gap-2"
>
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>

View file

@ -515,6 +515,36 @@ describe('FigmaAPI', () => {
expect(inner.layoutSizingHorizontal).toBe('FILL')
expect(inner.layoutMode).toBe('VERTICAL')
})
test('HORIZONTAL child in VERTICAL parent: sizing maps to correct raw axis', () => {
const api = createAPI()
const parent = api.createFrame()
parent.layoutMode = 'VERTICAL'
parent.resize(375, 812)
const child = api.createFrame()
child.layoutMode = 'HORIZONTAL'
parent.appendChild(child)
child.layoutSizingVertical = 'FIXED'
child.resize(375, 44)
expect(child.layoutSizingVertical).toBe('FIXED')
const raw = api.graph.getNode(child.id)!
expect(raw.counterAxisSizing).toBe('FIXED')
})
test('VERTICAL child in HORIZONTAL parent: sizing maps to correct raw axis', () => {
const api = createAPI()
const parent = api.createFrame()
parent.layoutMode = 'HORIZONTAL'
parent.resize(800, 600)
const child = api.createFrame()
child.layoutMode = 'VERTICAL'
parent.appendChild(child)
child.layoutSizingHorizontal = 'FIXED'
child.resize(200, 600)
expect(child.layoutSizingHorizontal).toBe('FIXED')
const raw = api.graph.getNode(child.id)!
expect(raw.counterAxisSizing).toBe('FIXED')
})
})
describe('frozen arrays', () => {

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
@ -1929,4 +1929,54 @@ describe('Grid Layout', () => {
expect(children[0].height).toBe(200)
})
})
describe('layoutSizingVertical with cross-axis children', () => {
test('HORIZONTAL children in VERTICAL parent respect FIXED height after layout', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const root = api.createFrame()
root.layoutMode = 'VERTICAL'
root.resize(375, 812)
api.currentPage.appendChild(root)
const statusBar = api.createFrame()
statusBar.layoutMode = 'HORIZONTAL'
root.appendChild(statusBar)
statusBar.layoutSizingHorizontal = 'FILL'
statusBar.layoutSizingVertical = 'FIXED'
statusBar.resize(375, 44)
const toolbar = api.createFrame()
toolbar.layoutMode = 'HORIZONTAL'
root.appendChild(toolbar)
toolbar.layoutSizingHorizontal = 'FILL'
toolbar.layoutSizingVertical = 'FIXED'
toolbar.resize(375, 52)
const canvas = api.createFrame()
root.appendChild(canvas)
canvas.layoutSizingHorizontal = 'FILL'
canvas.layoutSizingVertical = 'FILL'
const panel = api.createFrame()
panel.layoutMode = 'VERTICAL'
root.appendChild(panel)
panel.layoutSizingHorizontal = 'FILL'
panel.layoutSizingVertical = 'FIXED'
panel.resize(375, 220)
computeAllLayouts(graph)
const sb = graph.getNode(statusBar.id)!
const tb = graph.getNode(toolbar.id)!
const cv = graph.getNode(canvas.id)!
const pn = graph.getNode(panel.id)!
expect(sb.height).toBe(44)
expect(tb.height).toBe(52)
expect(pn.height).toBe(220)
expect(cv.height).toBe(812 - 44 - 52 - 220)
})
})
})