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:
parent
19bef49d4e
commit
d46b4b11a4
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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" })`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue