From d46b4b11a4c37d955cd16ec8fbd9571d8a756bda Mon Sep 17 00:00:00 2001 From: Anton A S Date: Thu, 12 Mar 2026 23:01:36 +0300 Subject: [PATCH] Fix layout axis mismatch, reduce describe noise, add bail-out strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/figma-api.ts | 918 +++++++++++++++++- packages/core/src/layout.ts | 31 +- packages/core/src/tools/describe-issues.ts | 2 +- .../core/src/tools/describe-layout-issues.ts | 33 +- src/ai/chat-debug.ts | 2 +- src/ai/system-prompt.md | 7 +- src/components/ChatPanel.vue | 6 +- tests/engine/figma-api.test.ts | 30 + tests/engine/layout.test.ts | 52 +- 9 files changed, 1049 insertions(+), 32 deletions(-) diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index b290d5261..48a7667a0 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -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 = 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 = 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 { + const n = this._raw() + const obj: Record = { + 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[] = [] diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 9dd41a54d..c82aaec5b 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -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) { diff --git a/packages/core/src/tools/describe-issues.ts b/packages/core/src/tools/describe-issues.ts index 2a2b2c26a..03b7b85c2 100644 --- a/packages/core/src/tools/describe-issues.ts +++ b/packages/core/src/tools/describe-issues.ts @@ -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) { diff --git a/packages/core/src/tools/describe-layout-issues.ts b/packages/core/src/tools/describe-layout-issues.ts index f9920756d..e9a466ec8 100644 --- a/packages/core/src/tools/describe-layout-issues.ts +++ b/packages/core/src/tools/describe-layout-issues.ts @@ -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 diff --git a/src/ai/chat-debug.ts b/src/ai/chat-debug.ts index afdb9ca60..3b2f18ae8 100644 --- a/src/ai/chat-debug.ts +++ b/src/ai/chat-debug.ts @@ -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' || diff --git a/src/ai/system-prompt.md b/src/ai/system-prompt.md index 4861c227b..d7adbe292 100644 --- a/src/ai/system-prompt.md +++ b/src/ai/system-prompt.md @@ -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" })`. diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index d0fcd6b9e..619fac0a1 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -103,11 +103,7 @@ function handleClearChat() { -
+
diff --git a/tests/engine/figma-api.test.ts b/tests/engine/figma-api.test.ts index 6c21ba18c..908906e90 100644 --- a/tests/engine/figma-api.test.ts +++ b/tests/engine/figma-api.test.ts @@ -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', () => { diff --git a/tests/engine/layout.test.ts b/tests/engine/layout.test.ts index 8a4bdc459..e03c71a99 100644 --- a/tests/engine/layout.test.ts +++ b/tests/engine/layout.test.ts @@ -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) + }) + }) })