diff --git a/.husky/pre-commit b/.husky/pre-commit
index 692f7c8..b1a974e 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,4 @@
-#!/usr/bin/env sh
-
-bun run lint
-bun run test
+#!/usr/bin/env sh
+
+bun run lint
+bun run test
diff --git a/package.json b/package.json
index 947688c..2c74eff 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"dev": "rspack dev",
"build": "rspack build",
"watch": "rspack build --watch",
- "test": "bun test --coverage",
+ "test": "tsc --noEmit && bun test --coverage",
"lint": "biome check --error-on-warnings",
"lint:fix": "biome check --fix",
"prepare": "husky"
diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap
index 3ea73d9..5830663 100644
--- a/src/__tests__/__snapshots__/code.test.ts.snap
+++ b/src/__tests__/__snapshots__/code.test.ts.snap
@@ -1,36 +1,55 @@
-// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
-
-exports[`registerCodegen should register codegen 1`] = `
-[
- {
- "code":
-"export function Test() {
- return
- }"
-,
- "language": "TYPESCRIPT",
- "title": "Test - Components",
- },
- {
- "code":
-"echo 'export function Test() {
- return
- }' > Test.tsx"
-,
- "language": "BASH",
- "title": "Test - Components CLI",
- },
-]
-`;
-
-exports[`registerCodegen should register codegen 2`] = `
-[
- {
- "code": "",
- "language": "TYPESCRIPT",
- "title": "Main",
- },
-]
-`;
-
-exports[`registerCodegen should register codegen 3`] = `[]`;
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`registerCodegen should register codegen 1`] = `
+[
+ {
+ "code":
+"export function Test() {
+ return
+ }"
+,
+ "language": "TYPESCRIPT",
+ "title": "Test - Components",
+ },
+ {
+ "code":
+"mkdir -p src/components
+
+echo 'import { Box } from \\'@devup-ui/react\\'
+
+export function Test() {
+ return
+ }' > src/components/Test.tsx"
+,
+ "language": "BASH",
+ "title": "Test - Components CLI (Bash)",
+ },
+ {
+ "code":
+"New-Item -ItemType Directory -Force -Path src\\components | Out-Null
+
+@'
+import { Box } from '@devup-ui/react'
+
+export function Test() {
+ return
+ }
+'@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8"
+,
+ "language": "BASH",
+ "title": "Test - Components CLI (PowerShell)",
+ },
+]
+`;
+
+exports[`registerCodegen should register codegen 2`] = `
+[
+ {
+ "code": "",
+ "language": "TYPESCRIPT",
+ "title": "Main",
+ },
+]
+`;
+
+exports[`registerCodegen should register codegen 3`] = `[]`;
diff --git a/src/__tests__/code-responsive.test.ts b/src/__tests__/code-responsive.test.ts
index 36ed476..502979e 100644
--- a/src/__tests__/code-responsive.test.ts
+++ b/src/__tests__/code-responsive.test.ts
@@ -25,7 +25,7 @@ const originalGenerateResponsiveCode =
describe('registerCodegen responsive error handling', () => {
beforeEach(() => {
- Codegen.prototype.run = runMock
+ Codegen.prototype.run = runMock as unknown as typeof Codegen.prototype.run
Codegen.prototype.getComponentsCodes = getComponentsCodesMock
Codegen.prototype.getCode = getCodeMock
ResponsiveCodegen.prototype.generateResponsiveCode =
@@ -48,9 +48,8 @@ describe('registerCodegen responsive error handling', () => {
})
test('swallows responsive errors and still returns base code', async () => {
- const handlerCalls: Parameters<
- Parameters[0]['codegen']['on']
- >[1][] = []
+ const handlerCalls: ((event: CodegenEvent) => Promise)[] =
+ []
const ctx = {
editorType: 'dev',
mode: 'codegen',
diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts
index 07ad1a2..295421a 100644
--- a/src/__tests__/code.test.ts
+++ b/src/__tests__/code.test.ts
@@ -95,6 +95,7 @@ describe('registerCodegen', () => {
node: {
type: 'COMPONENT',
name: 'Test',
+ visible: true,
},
language: 'devup-ui',
},
@@ -109,6 +110,7 @@ describe('registerCodegen', () => {
node: {
type: 'FRAME',
name: 'Main',
+ visible: true,
},
language: 'devup-ui',
},
@@ -123,6 +125,7 @@ describe('registerCodegen', () => {
node: {
type: 'FRAME',
name: 'Other',
+ visible: true,
},
language: 'other',
},
@@ -182,3 +185,32 @@ it('auto-runs on module load when figma is present', async () => {
expect(codegenOn).toHaveBeenCalledWith('generate', expect.any(Function))
})
+
+describe('extractImports', () => {
+ it('should extract keyframes import when code contains keyframes(', () => {
+ const result = codeModule.extractImports([
+ [
+ 'AnimatedBox',
+ '',
+ ],
+ ])
+ expect(result).toContain('keyframes')
+ expect(result).toContain('Box')
+ })
+
+ it('should extract keyframes import when code contains keyframes`', () => {
+ const result = codeModule.extractImports([
+ ['AnimatedBox', ''],
+ ])
+ expect(result).toContain('keyframes')
+ expect(result).toContain('Box')
+ })
+
+ it('should not extract keyframes when not present', () => {
+ const result = codeModule.extractImports([
+ ['SimpleBox', ''],
+ ])
+ expect(result).not.toContain('keyframes')
+ expect(result).toContain('Box')
+ })
+})
diff --git a/src/code-impl.ts b/src/code-impl.ts
index 91520ed..e98ba11 100644
--- a/src/code-impl.ts
+++ b/src/code-impl.ts
@@ -4,6 +4,80 @@ import { exportDevup, importDevup } from './commands/devup'
import { exportAssets } from './commands/exportAssets'
import { exportComponents } from './commands/exportComponents'
+export function extractImports(
+ componentsCodes: ReadonlyArray,
+): string[] {
+ const allCode = componentsCodes.map(([_, code]) => code).join('\n')
+ const imports = new Set()
+
+ const devupComponents = [
+ 'Center',
+ 'VStack',
+ 'Flex',
+ 'Grid',
+ 'Box',
+ 'Text',
+ 'Image',
+ ]
+
+ for (const component of devupComponents) {
+ const regex = new RegExp(`<${component}[\\s/>]`, 'g')
+ if (regex.test(allCode)) {
+ imports.add(component)
+ }
+ }
+
+ // keyframes 함수 체크
+ if (/keyframes\s*\(|keyframes`/.test(allCode)) {
+ imports.add('keyframes')
+ }
+
+ return Array.from(imports).sort()
+}
+
+function generateBashCLI(
+ componentsCodes: ReadonlyArray,
+): string {
+ const imports = extractImports(componentsCodes)
+ const importStatement =
+ imports.length > 0
+ ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n`
+ : ''
+
+ const commands = [
+ 'mkdir -p src/components',
+ '',
+ ...componentsCodes.map(([componentName, code]) => {
+ const fullCode = importStatement + code
+ const escapedCode = fullCode.replace(/'/g, "\\'")
+ return `echo '${escapedCode}' > src/components/${componentName}.tsx`
+ }),
+ ]
+
+ return commands.join('\n')
+}
+
+function generatePowerShellCLI(
+ componentsCodes: ReadonlyArray,
+): string {
+ const imports = extractImports(componentsCodes)
+ const importStatement =
+ imports.length > 0
+ ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n`
+ : ''
+
+ const commands = [
+ 'New-Item -ItemType Directory -Force -Path src\\components | Out-Null',
+ '',
+ ...componentsCodes.map(([componentName, code]) => {
+ const fullCode = importStatement + code
+ return `@'\n${fullCode}\n'@ | Out-File -FilePath src\\components\\${componentName}.tsx -Encoding UTF8`
+ }),
+ ]
+
+ return commands.join('\n')
+}
+
export function registerCodegen(ctx: typeof figma) {
if (ctx.editorType === 'dev' && ctx.mode === 'codegen') {
ctx.codegen.on('generate', async ({ node, language }) => {
@@ -15,7 +89,6 @@ export function registerCodegen(ctx: typeof figma) {
const componentsCodes = codegen.getComponentsCodes()
console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`)
- // 반응형 코드 생성 (부모가 Section인 경우)
const parentSection = ResponsiveCodegen.hasParentSection(node)
let responsiveResult: {
title: string
@@ -60,14 +133,14 @@ export function registerCodegen(ctx: typeof figma) {
code: componentsCodes.map((code) => code[1]).join('\n\n'),
},
{
- title: `${node.name} - Components CLI`,
+ title: `${node.name} - Components CLI (Bash)`,
+ language: 'BASH',
+ code: generateBashCLI(componentsCodes),
+ },
+ {
+ title: `${node.name} - Components CLI (PowerShell)`,
language: 'BASH',
- code: componentsCodes
- .map(
- ([componentName, code]) =>
- `echo '${code}' > ${componentName}.tsx`,
- )
- .join('\n'),
+ code: generatePowerShellCLI(componentsCodes),
},
] as const)
: []),
diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts
index 61856c1..aa716ae 100644
--- a/src/codegen/Codegen.ts
+++ b/src/codegen/Codegen.ts
@@ -3,12 +3,14 @@ import { getProps } from './props'
import { getSelectorProps } from './props/selector'
import { renderComponent, renderNode } from './render'
import { renderText } from './render/text'
+import type { ComponentTree, NodeTree } from './types'
import { checkAssetNode } from './utils/check-asset-node'
import { checkSameColor } from './utils/check-same-color'
import {
getDevupComponentByNode,
getDevupComponentByProps,
} from './utils/get-devup-component'
+import { getPageNode } from './utils/get-page-node'
import { buildCssUrl } from './utils/wrap-url'
export class Codegen {
@@ -18,6 +20,10 @@ export class Codegen {
> = new Map()
code: string = ''
+ // Tree representations
+ private tree: NodeTree | null = null
+ private componentTrees: Map = new Map()
+
constructor(private node: SceneNode) {
if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') {
this.node = node.parent
@@ -76,12 +82,12 @@ export class Codegen {
const assetNode = checkAssetNode(node)
if (assetNode) {
const props = await getProps(node)
- props.src = `/icons/${node.name}.${assetNode}`
+ props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
if (assetNode === 'svg') {
const maskColor = await checkSameColor(node)
if (maskColor) {
// support mask image icon
- props.maskImage = buildCssUrl(props.src)
+ props.maskImage = buildCssUrl(props.src as string)
props.maskRepeat = 'no-repeat'
props.maskSize = 'contain'
props.bg = maskColor
@@ -117,6 +123,12 @@ export class Codegen {
left: props.left,
right: props.right,
bottom: props.bottom,
+ w:
+ // if the node is a page root, set the width to 100%
+ (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode)
+ ?.width === node.width
+ ? '100%'
+ : undefined,
},
dep,
[ret],
@@ -150,4 +162,201 @@ export class Codegen {
if (node === this.node) this.code = ret
return ret
}
+
+ /**
+ * Build a NodeTree representation of the node hierarchy.
+ * This is the intermediate JSON representation that can be compared/merged.
+ */
+ async buildTree(node: SceneNode = this.node): Promise {
+ // Handle asset nodes (images/SVGs)
+ const assetNode = checkAssetNode(node)
+ if (assetNode) {
+ const props = await getProps(node)
+ props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
+ if (assetNode === 'svg') {
+ const maskColor = await checkSameColor(node)
+ if (maskColor) {
+ props.maskImage = buildCssUrl(props.src as string)
+ props.maskRepeat = 'no-repeat'
+ props.maskSize = 'contain'
+ props.bg = maskColor
+ delete props.src
+ }
+ }
+ return {
+ component: 'src' in props ? 'Image' : 'Box',
+ props,
+ children: [],
+ nodeType: node.type,
+ nodeName: node.name,
+ }
+ }
+
+ const props = await getProps(node)
+
+ // Handle COMPONENT_SET or COMPONENT - add to componentTrees
+ if (
+ (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') &&
+ ((this.node.type === 'COMPONENT_SET' &&
+ node === this.node.defaultVariant) ||
+ this.node.type === 'COMPONENT')
+ ) {
+ await this.addComponentTree(
+ node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
+ )
+ }
+
+ // Handle INSTANCE nodes - treat as component reference
+ if (node.type === 'INSTANCE') {
+ const mainComponent = await node.getMainComponentAsync()
+ if (mainComponent) await this.addComponentTree(mainComponent)
+
+ const componentName = getComponentName(mainComponent || node)
+
+ // Check if needs position wrapper
+ if (props.pos) {
+ return {
+ component: 'Box',
+ props: {
+ pos: props.pos,
+ top: props.top,
+ left: props.left,
+ right: props.right,
+ bottom: props.bottom,
+ transform: props.transform,
+ w:
+ (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode)
+ ?.width === node.width
+ ? '100%'
+ : undefined,
+ },
+ children: [
+ {
+ component: componentName,
+ props: {},
+ children: [],
+ nodeType: node.type,
+ nodeName: node.name,
+ isComponent: true,
+ },
+ ],
+ nodeType: 'WRAPPER',
+ nodeName: `${node.name}_wrapper`,
+ }
+ }
+
+ return {
+ component: componentName,
+ props: {},
+ children: [],
+ nodeType: node.type,
+ nodeName: node.name,
+ isComponent: true,
+ }
+ }
+
+ // Build children recursively
+ const children: NodeTree[] = []
+ if ('children' in node) {
+ for (const child of node.children) {
+ if (child.type === 'INSTANCE') {
+ const mainComponent = await child.getMainComponentAsync()
+ if (mainComponent) await this.addComponentTree(mainComponent)
+ }
+ children.push(await this.buildTree(child))
+ }
+ }
+
+ // Handle TEXT nodes
+ let textChildren: string[] | undefined
+ if (node.type === 'TEXT') {
+ const { children: textContent, props: textProps } = await renderText(node)
+ textChildren = textContent
+ Object.assign(props, textProps)
+ }
+
+ const component = getDevupComponentByNode(node, props)
+
+ return {
+ component,
+ props,
+ children,
+ nodeType: node.type,
+ nodeName: node.name,
+ textChildren,
+ }
+ }
+
+ /**
+ * Get the NodeTree representation of the node.
+ * Builds the tree if not already built.
+ */
+ async getTree(): Promise {
+ if (!this.tree) {
+ this.tree = await this.buildTree(this.node)
+ }
+ return this.tree
+ }
+
+ /**
+ * Get component trees (for COMPONENT_SET/COMPONENT nodes).
+ */
+ getComponentTrees(): Map {
+ return this.componentTrees
+ }
+
+ /**
+ * Add a component to componentTrees.
+ */
+ private async addComponentTree(node: ComponentNode): Promise {
+ if (this.componentTrees.has(node)) return
+
+ const childrenTrees: NodeTree[] = []
+ if ('children' in node) {
+ for (const child of node.children) {
+ if (child.type === 'INSTANCE') {
+ const mainComponent = await child.getMainComponentAsync()
+ if (mainComponent) await this.addComponentTree(mainComponent)
+ }
+ childrenTrees.push(await this.buildTree(child))
+ }
+ }
+
+ const props = await getProps(node)
+ const selectorProps = await getSelectorProps(node)
+ const variants: Record = {}
+
+ if (selectorProps) {
+ Object.assign(props, selectorProps.props)
+ Object.assign(variants, selectorProps.variants)
+ }
+
+ this.componentTrees.set(node, {
+ name: getComponentName(node),
+ tree: {
+ component: getDevupComponentByProps(props),
+ props,
+ children: childrenTrees,
+ nodeType: node.type,
+ nodeName: node.name,
+ },
+ variants,
+ })
+ }
+
+ /**
+ * Render a NodeTree to JSX string.
+ * Static method so it can be used independently.
+ */
+ static renderTree(tree: NodeTree, depth: number = 0): string {
+ // Handle TEXT nodes with textChildren
+ if (tree.textChildren && tree.textChildren.length > 0) {
+ return renderNode(tree.component, tree.props, depth, tree.textChildren)
+ }
+
+ const childrenCodes = tree.children.map((child) =>
+ Codegen.renderTree(child, depth + 1),
+ )
+ return renderNode(tree.component, tree.props, depth, childrenCodes)
+ }
}
diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts
index 955259a..f24f1aa 100644
--- a/src/codegen/__tests__/codegen.test.ts
+++ b/src/codegen/__tests__/codegen.test.ts
@@ -107,7 +107,27 @@ function createTextSegment(characters: string): StyledTextSegment {
} as unknown as StyledTextSegment
}
+function addVisibleToAll(node: SceneNode, visited = new Set()) {
+ if (visited.has(node)) return
+ visited.add(node)
+ if (!('visible' in node)) {
+ ;(node as unknown as { visible: boolean }).visible = true
+ }
+ if ('children' in node) {
+ for (const child of node.children) {
+ addVisibleToAll(child, visited)
+ }
+ }
+ if ('parent' in node && node.parent) {
+ addVisibleToAll(node.parent as SceneNode, visited)
+ }
+ if ('defaultVariant' in node && node.defaultVariant) {
+ addVisibleToAll(node.defaultVariant as SceneNode, visited)
+ }
+}
+
function addParent(parent: SceneNode) {
+ addVisibleToAll(parent)
if ('children' in parent) {
for (const child of parent.children) {
;(child as unknown as { parent: SceneNode }).parent = parent
@@ -162,7 +182,7 @@ describe('Codegen', () => {
},
],
} as unknown as RectangleNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders objectFit cover for image asset',
@@ -183,7 +203,7 @@ describe('Codegen', () => {
},
],
} as unknown as RectangleNode,
- expected: ``,
+ expected: ``,
},
{
title: 'omits objectFit when image scale mode is FILL',
@@ -204,7 +224,7 @@ describe('Codegen', () => {
},
],
} as unknown as RectangleNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders svg asset with vector node',
@@ -562,11 +582,13 @@ describe('Codegen', () => {
type: 'RECTANGLE',
name: 'AbsoluteChild',
layoutPositioning: 'ABSOLUTE',
+ x: 0,
+ y: 0,
width: 300,
height: 200,
constraints: {
- horizontal: 'MAX',
- vertical: 'MAX',
+ horizontal: 'MIN',
+ vertical: 'MIN',
},
},
],
@@ -602,7 +624,7 @@ describe('Codegen', () => {
],
} as unknown as FrameNode,
expected: `
-
+
`,
},
{
@@ -633,12 +655,11 @@ describe('Codegen', () => {
} as unknown as FrameNode,
expected: `
`,
},
@@ -669,7 +690,7 @@ describe('Codegen', () => {
],
} as unknown as FrameNode,
expected: `
-
+
`,
},
{
@@ -700,11 +721,11 @@ describe('Codegen', () => {
} as unknown as FrameNode,
expected: `
`,
},
@@ -1215,7 +1236,7 @@ describe('Codegen', () => {
name: 'Section 1',
},
} as unknown as FrameNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders frame with vertical center align child width shrinker',
@@ -1493,13 +1514,7 @@ describe('Codegen', () => {
primaryAxisAlignItems: 'SPACE_BETWEEN',
counterAxisAlignItems: 'CENTER',
} as unknown as FrameNode,
- expected: `
+ expected: `
`,
@@ -1611,7 +1626,7 @@ describe('Codegen', () => {
},
],
} as unknown as FrameNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders noise effect props',
@@ -1674,7 +1689,7 @@ describe('Codegen', () => {
},
],
} as unknown as FrameNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders text node with content',
@@ -2115,7 +2130,7 @@ describe('Codegen', () => {
height: 50,
rotation: 45,
} as unknown as FrameNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders frame with negative rotation transform',
@@ -2129,7 +2144,8 @@ describe('Codegen', () => {
height: 40,
rotation: -30,
} as unknown as FrameNode,
- expected: ``,
+ // revsered rotation
+ expected: ``,
},
{
title: 'renders frame with decimal rotation transform',
@@ -2143,7 +2159,7 @@ describe('Codegen', () => {
height: 60,
rotation: 15.5,
} as unknown as FrameNode,
- expected: ``,
+ expected: ``,
},
{
title: 'renders frame with opacity less than 1',
@@ -3021,18 +3037,18 @@ describe('Codegen', () => {
} as unknown as ComponentSetNode
})(),
expected: `
-
-
+
+
`,
expectedComponents: [
[
'Button',
`export interface ButtonProps {
- state: default | hover
+ state: 'default' | 'hover'
}
export function Button() {
- return
+ return
}`,
],
],
@@ -3068,14 +3084,14 @@ export function Button() {
} as unknown as ComponentSetNode
})(),
expected: `
-
-
+
+
`,
expectedComponents: [
[
'Button',
`export function Button() {
- return
+ return
}`,
],
],
@@ -3134,14 +3150,14 @@ export function Button() {
} as unknown as ComponentSetNode
})(),
expected: `
-
-
+
+
`,
expectedComponents: [
[
'Button',
`export function Button() {
- return
+ return
}`,
],
],
@@ -3201,8 +3217,8 @@ export function Button() {
} as unknown as ComponentSetNode
})(),
expected: `
-
-
+
+
`,
expectedComponents: [
[
@@ -3213,7 +3229,7 @@ export function Button() {
_hover={{
"opacity": "0.8"
}}
- boxSize="100%"
+ h="100%"
transition="0.3ms ease-in-out"
transitionProperty="opacity"
/>
@@ -3311,14 +3327,14 @@ export function Button() {
} as unknown as ComponentSetNode
})(),
expected: `
-
-
+
+
`,
expectedComponents: [
[
'Button',
`export function Button() {
- return
+ return
}`,
],
],
@@ -3363,4 +3379,808 @@ export function Button() {
expect(codegen.getCode()).toBe(expected)
expect(componentsCodes).toEqual(expectedComponents)
})
+
+ test('renders instance with page root width and sets width to 100%', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'TestComponent',
+ children: [],
+ getMainComponentAsync: async () => null,
+ } as unknown as ComponentNode
+
+ const pageNode = {
+ type: 'PAGE',
+ } as unknown as PageNode
+
+ const pageRootNode = {
+ type: 'FRAME',
+ name: 'PageRoot',
+ parent: pageNode,
+ width: 1440,
+ height: 900,
+ } as unknown as FrameNode
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'TestInstance',
+ parent: pageRootNode,
+ width: 1440,
+ height: 100,
+ x: 100,
+ y: 50,
+ getMainComponentAsync: async () => mainComponent,
+ layoutPositioning: 'ABSOLUTE',
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ } as unknown as InstanceNode
+
+ const codegen = new Codegen(instanceNode)
+ await codegen.run()
+ const code = codegen.getCode()
+
+ expect(code).toContain('w="100%"')
+ })
+
+ test('renders instance without page root width match and does not set width to 100%', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'TestComponent',
+ children: [],
+ getMainComponentAsync: async () => null,
+ } as unknown as ComponentNode
+
+ const pageNode = {
+ type: 'PAGE',
+ } as unknown as PageNode
+
+ const pageRootNode = {
+ type: 'FRAME',
+ name: 'PageRoot',
+ parent: pageNode,
+ width: 1440,
+ height: 900,
+ } as unknown as FrameNode
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'TestInstance',
+ parent: pageRootNode,
+ width: 800,
+ height: 100,
+ x: 100,
+ y: 50,
+ getMainComponentAsync: async () => mainComponent,
+ layoutPositioning: 'ABSOLUTE',
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ } as unknown as InstanceNode
+
+ const codegen = new Codegen(instanceNode)
+ await codegen.run()
+ const code = codegen.getCode()
+
+ expect(code).not.toContain('w="100%"')
+ })
+})
+
+describe('Codegen Tree Methods', () => {
+ describe('buildTree', () => {
+ test('builds tree for simple frame', async () => {
+ const node = {
+ type: 'FRAME',
+ name: 'SimpleFrame',
+ children: [],
+ visible: true,
+ } as unknown as FrameNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Box')
+ expect(tree.nodeType).toBe('FRAME')
+ expect(tree.nodeName).toBe('SimpleFrame')
+ expect(tree.children).toEqual([])
+ })
+
+ test('builds tree for asset node (image)', async () => {
+ const node = {
+ type: 'RECTANGLE',
+ name: 'TestImage',
+ isAsset: true,
+ children: [],
+ visible: true,
+ fills: [
+ {
+ type: 'IMAGE',
+ visible: true,
+ },
+ ],
+ } as unknown as RectangleNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Image')
+ expect(tree.props.src).toBe('/images/TestImage.png')
+ expect(tree.nodeType).toBe('RECTANGLE')
+ })
+
+ test('builds tree for SVG asset with mask color', async () => {
+ const node = {
+ type: 'VECTOR',
+ name: 'TestIcon',
+ isAsset: true,
+ children: [],
+ visible: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as VectorNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Box')
+ expect(tree.props.maskImage).toBe('url(/icons/TestIcon.svg)')
+ expect(tree.props.maskRepeat).toBe('no-repeat')
+ expect(tree.props.maskSize).toBe('contain')
+ expect(tree.props.bg).toBe('#F00')
+ expect(tree.props.src).toBeUndefined()
+ })
+
+ test('builds tree for SVG asset without same color (returns Image)', async () => {
+ const node = {
+ type: 'FRAME',
+ name: 'MultiColorIcon',
+ isAsset: true,
+ children: [
+ {
+ type: 'VECTOR',
+ name: 'Part1',
+ visible: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ {
+ type: 'VECTOR',
+ name: 'Part2',
+ visible: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 1, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ visible: true,
+ fills: [],
+ } as unknown as FrameNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Image')
+ expect(tree.props.src).toBe('/icons/MultiColorIcon.svg')
+ })
+
+ test('builds tree for frame with children', async () => {
+ const child1 = {
+ type: 'FRAME',
+ name: 'Child1',
+ children: [],
+ visible: true,
+ } as unknown as FrameNode
+
+ const child2 = {
+ type: 'FRAME',
+ name: 'Child2',
+ children: [],
+ visible: true,
+ } as unknown as FrameNode
+
+ const node = {
+ type: 'FRAME',
+ name: 'ParentFrame',
+ children: [child1, child2],
+ visible: true,
+ inferredAutoLayout: {
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 8,
+ },
+ primaryAxisAlignItems: 'MIN',
+ counterAxisAlignItems: 'MIN',
+ } as unknown as FrameNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Flex')
+ expect(tree.children.length).toBe(2)
+ expect(tree.children[0].nodeName).toBe('Child1')
+ expect(tree.children[1].nodeName).toBe('Child2')
+ })
+
+ test('builds tree for TEXT node', async () => {
+ const node = {
+ type: 'TEXT',
+ name: 'TextNode',
+ characters: 'Hello World',
+ visible: true,
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ textAlignHorizontal: 'LEFT',
+ textAlignVertical: 'TOP',
+ strokes: [],
+ effects: [],
+ getStyledTextSegments: () => [createTextSegment('Hello World')],
+ } as unknown as TextNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Text')
+ expect(tree.nodeType).toBe('TEXT')
+ expect(tree.textChildren).toBeDefined()
+ })
+
+ test('builds tree for INSTANCE node without position wrapper', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'MainComponent',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'InstanceNode',
+ visible: true,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+ addParent(instanceNode)
+
+ const codegen = new Codegen(instanceNode)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('MainComponent')
+ expect(tree.isComponent).toBe(true)
+ expect(tree.props).toEqual({})
+ })
+
+ test('builds tree for INSTANCE node with position wrapper (absolute)', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'AbsoluteComponent',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const parent = {
+ type: 'FRAME',
+ name: 'Parent',
+ children: [],
+ visible: true,
+ width: 500,
+ } as unknown as FrameNode
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'AbsoluteInstance',
+ visible: true,
+ width: 100,
+ height: 50,
+ x: 10,
+ y: 20,
+ layoutPositioning: 'ABSOLUTE',
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ getMainComponentAsync: async () => mainComponent,
+ parent,
+ } as unknown as InstanceNode
+
+ ;(parent as unknown as { children: SceneNode[] }).children = [
+ instanceNode,
+ ]
+ addParent(parent)
+
+ const codegen = new Codegen(instanceNode)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Box')
+ expect(tree.props.pos).toBe('absolute')
+ expect(tree.children.length).toBe(1)
+ expect(tree.children[0].component).toBe('AbsoluteComponent')
+ expect(tree.children[0].isComponent).toBe(true)
+ })
+
+ test('builds tree for INSTANCE with position and 100% width', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'FullWidthComponent',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const page = {
+ type: 'PAGE',
+ name: 'Page',
+ width: 200,
+ parent: null,
+ } as unknown as PageNode
+
+ const parent = {
+ type: 'FRAME',
+ name: 'PageRoot',
+ children: [],
+ visible: true,
+ width: 200,
+ parent: page,
+ } as unknown as FrameNode
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'FullWidthInstance',
+ visible: true,
+ width: 200,
+ height: 50,
+ x: 0,
+ y: 0,
+ layoutPositioning: 'ABSOLUTE',
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ getMainComponentAsync: async () => mainComponent,
+ parent,
+ } as unknown as InstanceNode
+
+ ;(parent as unknown as { children: SceneNode[] }).children = [
+ instanceNode,
+ ]
+ addParent(parent)
+
+ const codegen = new Codegen(instanceNode)
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toBe('Box')
+ expect(tree.props.w).toBe('100%')
+ })
+
+ test('builds tree for COMPONENT_SET node', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'ButtonSet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {},
+ } as unknown as ComponentSetNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBeGreaterThan(0)
+ })
+
+ test('builds tree for COMPONENT node directly', async () => {
+ const node = {
+ type: 'COMPONENT',
+ name: 'DirectComponent',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBeGreaterThan(0)
+ })
+
+ test('builds tree with nested INSTANCE children', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'NestedComp',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const instanceChild = {
+ type: 'INSTANCE',
+ name: 'NestedInstance',
+ visible: true,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+
+ const parent = {
+ type: 'FRAME',
+ name: 'ParentWithInstance',
+ children: [instanceChild],
+ visible: true,
+ } as unknown as FrameNode
+ addParent(parent)
+
+ const codegen = new Codegen(parent)
+ const tree = await codegen.buildTree()
+
+ expect(tree.children.length).toBe(1)
+ expect(tree.children[0].isComponent).toBe(true)
+ })
+ })
+
+ describe('getTree', () => {
+ test('builds and caches tree on first call', async () => {
+ const node = {
+ type: 'FRAME',
+ name: 'CachedFrame',
+ children: [],
+ visible: true,
+ } as unknown as FrameNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ const tree1 = await codegen.getTree()
+ const tree2 = await codegen.getTree()
+
+ expect(tree1).toBe(tree2) // Same reference (cached)
+ expect(tree1.nodeName).toBe('CachedFrame')
+ })
+ })
+
+ describe('getComponentTrees', () => {
+ test('returns empty map when no components', async () => {
+ const node = {
+ type: 'FRAME',
+ name: 'NoComponents',
+ children: [],
+ visible: true,
+ } as unknown as FrameNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBe(0)
+ })
+
+ test('returns component trees after building', async () => {
+ const componentChild = {
+ type: 'COMPONENT',
+ name: 'ChildComp',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'Default',
+ children: [componentChild],
+ visible: true,
+ reactions: [],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'CompSet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {},
+ } as unknown as ComponentSetNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBeGreaterThan(0)
+ })
+ })
+
+ describe('addComponentTree (via buildTree)', () => {
+ test('adds component with selector props', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'State=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ } as unknown as ComponentNode
+
+ const hoverVariant = {
+ type: 'COMPONENT',
+ name: 'State=Hover',
+ children: [],
+ visible: true,
+ reactions: [],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0.5, b: 1 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'ButtonWithHover',
+ children: [defaultVariant, hoverVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {},
+ } as unknown as ComponentSetNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBeGreaterThan(0)
+ })
+
+ test('does not duplicate component trees', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'SharedComp',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const instance1 = {
+ type: 'INSTANCE',
+ name: 'Instance1',
+ visible: true,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+
+ const instance2 = {
+ type: 'INSTANCE',
+ name: 'Instance2',
+ visible: true,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+
+ const parent = {
+ type: 'FRAME',
+ name: 'ParentWithDuplicates',
+ children: [instance1, instance2],
+ visible: true,
+ } as unknown as FrameNode
+ addParent(parent)
+
+ const codegen = new Codegen(parent)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ // Should only have 1 entry for SharedComp, not duplicates
+ expect(componentTrees.size).toBe(1)
+ })
+
+ test('handles component with INSTANCE children', async () => {
+ const nestedComponent = {
+ type: 'COMPONENT',
+ name: 'NestedComp',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+ addParent(nestedComponent)
+
+ const nestedInstance = {
+ type: 'INSTANCE',
+ name: 'NestedInstance',
+ visible: true,
+ getMainComponentAsync: async () => nestedComponent,
+ } as unknown as InstanceNode
+
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'ParentComp',
+ children: [nestedInstance],
+ visible: true,
+ reactions: [],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'CompSetWithNestedInstance',
+ children: [mainComponent],
+ defaultVariant: mainComponent,
+ visible: true,
+ componentPropertyDefinitions: {},
+ } as unknown as ComponentSetNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBe(2) // ParentComp and NestedComp
+ })
+ })
+
+ describe('renderTree (static)', () => {
+ test('renders simple tree to JSX', () => {
+ const tree = {
+ component: 'Box',
+ props: { w: '100px', h: '50px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'SimpleBox',
+ }
+
+ const result = Codegen.renderTree(tree)
+ expect(result).toContain(' {
+ const tree = {
+ component: 'Flex',
+ props: { direction: 'column' },
+ children: [
+ {
+ component: 'Box',
+ props: { w: '100px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Child1',
+ },
+ {
+ component: 'Box',
+ props: { h: '50px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Child2',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Parent',
+ }
+
+ const result = Codegen.renderTree(tree)
+ expect(result).toContain(' {
+ const tree = {
+ component: 'Text',
+ props: { fontSize: '16px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'TextNode',
+ textChildren: ['Hello', ' ', 'World'],
+ }
+
+ const result = Codegen.renderTree(tree)
+ expect(result).toContain(' {
+ const tree = {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Box',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'DeepChild',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'MiddleChild',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ }
+
+ const result = Codegen.renderTree(tree, 0)
+ expect(result).toContain(' {
+ const tree = {
+ component: 'MyButton',
+ props: {},
+ children: [],
+ nodeType: 'INSTANCE',
+ nodeName: 'ButtonInstance',
+ isComponent: true,
+ }
+
+ const result = Codegen.renderTree(tree)
+ expect(result).toContain(' {
+ test('sanitizes property name that is only digits', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: '123=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { '123': 'Default' },
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'NumericPropertySet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ '123': {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Active'],
+ },
+ },
+ } as unknown as ComponentSetNode
+ addParent(node)
+
+ const codegen = new Codegen(node)
+ await codegen.buildTree()
+
+ // The numeric property name should be sanitized to 'variant'
+ const componentTrees = codegen.getComponentTrees()
+ expect(componentTrees.size).toBeGreaterThan(0)
+ })
+ })
})
diff --git a/src/codegen/props/__tests__/cursor.test.ts b/src/codegen/props/__tests__/cursor.test.ts
new file mode 100644
index 0000000..fc660ea
--- /dev/null
+++ b/src/codegen/props/__tests__/cursor.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, test } from 'bun:test'
+import { getCursorProps } from '../cursor'
+
+describe('getCursorProps', () => {
+ test('returns empty object when node has no reactions property', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ } as SceneNode
+
+ expect(getCursorProps(node)).toEqual({})
+ })
+
+ test('returns empty object when node.reactions is null', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ reactions: null,
+ } as unknown as SceneNode
+
+ expect(getCursorProps(node)).toEqual({})
+ })
+
+ test('returns empty object when node.reactions is empty array', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(getCursorProps(node)).toEqual({})
+ })
+
+ test('returns cursor: pointer when reaction has ON_CLICK trigger', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ reactions: [
+ {
+ trigger: { type: 'ON_CLICK' },
+ actions: [{ type: 'NODE', destinationId: '123' }],
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(getCursorProps(node)).toEqual({ cursor: 'pointer' })
+ })
+
+ test('returns empty object when reaction has non-click trigger', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ reactions: [
+ {
+ trigger: { type: 'ON_HOVER' },
+ actions: [{ type: 'NODE', destinationId: '123' }],
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(getCursorProps(node)).toEqual({})
+ })
+
+ test('returns cursor: pointer when any reaction has ON_CLICK trigger among multiple', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ reactions: [
+ {
+ trigger: { type: 'ON_HOVER' },
+ actions: [{ type: 'NODE', destinationId: '123' }],
+ },
+ {
+ trigger: { type: 'ON_CLICK' },
+ actions: [{ type: 'NODE', destinationId: '456' }],
+ },
+ {
+ trigger: { type: 'AFTER_TIMEOUT' },
+ actions: [{ type: 'NODE', destinationId: '789' }],
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(getCursorProps(node)).toEqual({ cursor: 'pointer' })
+ })
+
+ test('returns empty object when reaction trigger is undefined', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'Test',
+ reactions: [
+ {
+ trigger: undefined,
+ actions: [{ type: 'NODE', destinationId: '123' }],
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(getCursorProps(node)).toEqual({})
+ })
+})
diff --git a/src/codegen/props/__tests__/position.test.ts b/src/codegen/props/__tests__/position.test.ts
new file mode 100644
index 0000000..75aa299
--- /dev/null
+++ b/src/codegen/props/__tests__/position.test.ts
@@ -0,0 +1,435 @@
+import { describe, expect, it, vi } from 'vitest'
+import { canBeAbsolute, getPositionProps } from '../position'
+
+vi.mock('../../utils/check-asset-node', () => ({
+ checkAssetNode: () => null,
+}))
+
+vi.mock('../../utils/is-page-root', () => ({
+ isPageRoot: () => false,
+}))
+
+describe('position', () => {
+ describe('canBeAbsolute', () => {
+ it('should return true for ABSOLUTE positioned node', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ parent: {
+ type: 'FRAME',
+ },
+ } as any
+
+ expect(canBeAbsolute(node)).toBe(true)
+ })
+
+ it('should return true for AUTO positioned node in freelayout parent with constraints', () => {
+ const node = {
+ layoutPositioning: 'AUTO',
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ parent: {
+ layoutPositioning: 'AUTO',
+ width: 100,
+ height: 100,
+ type: 'FRAME',
+ },
+ } as any
+
+ expect(canBeAbsolute(node)).toBe(true)
+ })
+
+ it('should return false for AUTO positioned node in freelayout parent without constraints', () => {
+ const node = {
+ layoutPositioning: 'AUTO',
+ parent: {
+ layoutPositioning: 'AUTO',
+ width: 100,
+ height: 100,
+ type: 'FRAME',
+ },
+ } as any
+
+ expect(canBeAbsolute(node)).toBe(false)
+ })
+
+ it('should return false for node without parent', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ } as any
+
+ expect(canBeAbsolute(node)).toBe(false)
+ })
+ })
+
+ describe('getPositionProps', () => {
+ it('should return absolute position props with constraints', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ parent: {
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '10px',
+ right: undefined,
+ top: '20px',
+ bottom: undefined,
+ })
+ })
+
+ it('should return absolute position props in freelayout using fallback x/y', () => {
+ const node = {
+ layoutPositioning: 'AUTO',
+ x: 15,
+ y: 25,
+ width: 40,
+ height: 50,
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ parent: {
+ layoutPositioning: 'AUTO',
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '15px',
+ right: undefined,
+ top: '25px',
+ bottom: undefined,
+ })
+ })
+
+ it('should return undefined when no constraints in freelayout and canBeAbsolute is false', () => {
+ const node = {
+ layoutPositioning: 'AUTO',
+ x: 15,
+ y: 25,
+ width: 40,
+ height: 50,
+ parent: {
+ layoutPositioning: 'AUTO',
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should return absolute position with x/y when ABSOLUTE node has no constraints in freelayout parent', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 15,
+ y: 25,
+ width: 40,
+ height: 50,
+ parent: {
+ layoutPositioning: 'AUTO',
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '15px',
+ top: '25px',
+ })
+ })
+
+ it('should return undefined when no constraints and parent is not freelayout (line 43)', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ parent: {
+ inferredAutoLayout: {
+ layoutMode: 'HORIZONTAL',
+ },
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should return relative position for parent with absolute children', () => {
+ const node = {
+ type: 'FRAME',
+ children: [
+ {
+ layoutPositioning: 'ABSOLUTE',
+ },
+ ],
+ } as any
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'relative',
+ })
+ })
+
+ it('should return relative position for freelayout parent with AUTO children', () => {
+ const node = {
+ type: 'FRAME',
+ layoutPositioning: 'AUTO',
+ children: [
+ {
+ layoutPositioning: 'AUTO',
+ },
+ ],
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'relative',
+ })
+ })
+
+ it('should handle MAX constraints', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ constraints: {
+ horizontal: 'MAX',
+ vertical: 'MAX',
+ },
+ parent: {
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: undefined,
+ right: '140px', // 200 - 10 - 50
+ top: undefined,
+ bottom: '220px', // 300 - 20 - 60
+ })
+ })
+
+ it('should handle CENTER constraints (default case)', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ constraints: {
+ horizontal: 'CENTER',
+ vertical: 'CENTER',
+ },
+ parent: {
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '50%',
+ right: undefined,
+ top: '50%',
+ bottom: undefined,
+ transform: 'translate(-50%, -50%)',
+ })
+ })
+
+ it('should get constraints from children[0] when node has no constraints', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 5,
+ y: 10,
+ width: 30,
+ height: 40,
+ children: [
+ {
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ },
+ ],
+ parent: {
+ type: 'FRAME',
+ width: 100,
+ height: 150,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '5px',
+ right: undefined,
+ top: '10px',
+ bottom: undefined,
+ })
+ })
+
+ it('should return undefined when children[0] exists but has no constraints and parent is not freelayout', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 5,
+ y: 10,
+ width: 30,
+ height: 40,
+ children: [
+ {
+ // no constraints
+ },
+ ],
+ parent: {
+ inferredAutoLayout: {
+ layoutMode: 'VERTICAL',
+ },
+ type: 'FRAME',
+ width: 100,
+ height: 150,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle SCALE horizontal constraint (default case)', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ constraints: {
+ horizontal: 'SCALE',
+ vertical: 'MIN',
+ },
+ parent: {
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '0px',
+ right: '0px',
+ top: '20px',
+ bottom: undefined,
+ transform: undefined,
+ })
+ })
+
+ it('should handle STRETCH vertical constraint (default case)', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'STRETCH',
+ },
+ parent: {
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '10px',
+ right: undefined,
+ top: '0px',
+ bottom: '0px',
+ transform: undefined,
+ })
+ })
+
+ it('should handle both SCALE constraints (default case for both)', () => {
+ const node = {
+ layoutPositioning: 'ABSOLUTE',
+ x: 10,
+ y: 20,
+ width: 50,
+ height: 60,
+ constraints: {
+ horizontal: 'SCALE',
+ vertical: 'SCALE',
+ },
+ parent: {
+ type: 'FRAME',
+ width: 200,
+ height: 300,
+ },
+ } as any
+
+ const result = getPositionProps(node)
+
+ expect(result).toEqual({
+ pos: 'absolute',
+ left: '0px',
+ right: '0px',
+ top: '0px',
+ bottom: '0px',
+ transform: undefined,
+ })
+ })
+ })
+})
diff --git a/src/codegen/props/__tests__/reaction.test.ts b/src/codegen/props/__tests__/reaction.test.ts
index 3837cac..55fa12f 100644
--- a/src/codegen/props/__tests__/reaction.test.ts
+++ b/src/codegen/props/__tests__/reaction.test.ts
@@ -3,8 +3,15 @@ import { getReactionProps } from '../reaction'
// Mock figma global
const mockGetNodeByIdAsync = vi.fn()
+const mockGetVariableByIdAsync = vi.fn()
;(global as any).figma = {
getNodeByIdAsync: mockGetNodeByIdAsync,
+ util: {
+ rgba: (color: any) => color,
+ },
+ variables: {
+ getVariableByIdAsync: mockGetVariableByIdAsync,
+ },
}
describe('getReactionProps', () => {
@@ -232,7 +239,7 @@ describe('getReactionProps', () => {
expect(result.animationName).toBeDefined()
expect(result.animationName).toContain('bg')
- expect(result.animationName).toContain('rgb(0, 255, 0)')
+ expect(result.animationName).toContain('#0F0') // hex format instead of rgb
})
it('should return empty object when no changes detected', async () => {
@@ -455,9 +462,11 @@ describe('getReactionProps', () => {
const result = await getReactionProps(node1)
- // Should stop at node2 and not loop back to node1
+ // Should stop at node2 and not loop back to node1 (prevents infinite recursion)
+ // But it's detected as a loop, so duration includes return-to-initial step
expect(result.animationName).toBeDefined()
- expect(result.animationDuration).toBe('0.5s') // Only one transition
+ expect(result.animationDuration).toBe('1s') // 0.5s + 0.5s (return to initial)
+ expect(result.animationIterationCount).toBe('infinite')
})
it('should prevent infinite loops with self-referencing nodes', async () => {
@@ -611,7 +620,7 @@ describe('getReactionProps', () => {
expect(buttonAnimation).toContain('100%')
expect(buttonAnimation).toContain('translate(100px, 0px)') // Position change
expect(buttonAnimation).toContain('0.5') // Opacity change
- expect(buttonAnimation).toContain('rgb(0, 255, 0)') // Color change
+ expect(buttonAnimation).toContain('#0F0') // Color change (hex format)
// Text child should get its animation from cache
const textResult = await getReactionProps(textChild)
@@ -623,7 +632,7 @@ describe('getReactionProps', () => {
expect(textAnimation).toContain('0.8') // Opacity change
})
- it('should detect loop animations and add infinite iteration count', async () => {
+ it('should detect loop animations and add infinite iteration count with 100% returning to initial', async () => {
const node1 = {
id: 'node1',
type: 'FRAME',
@@ -680,8 +689,1312 @@ describe('getReactionProps', () => {
const result = await getReactionProps(node1)
+ expect(result.animationName).toBeDefined()
+ // Duration should include the return-to-initial step: 0.5s + 0.5s = 1s
+ expect(result.animationDuration).toBe('1s')
+ expect(result.animationIterationCount).toBe('infinite')
+
+ // Check that keyframes include 100% returning to initial state
+ const animationName = result.animationName as string
+ expect(animationName).toContain('100%')
+ // The 50% keyframe should contain the changes, and 100% should return to initial
+ expect(animationName).toContain('50%')
+ })
+
+ it('should return cached child animation from parent cache', async () => {
+ const buttonChild = {
+ id: 'child1',
+ name: 'Button',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ } as any
+
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [buttonChild],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0.01,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-new',
+ name: 'Button',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ // First call to parent should populate cache
+ await getReactionProps(parentNode)
+
+ // Second call to child should use cached value
+ const childResult = await getReactionProps(buttonChild)
+
+ expect(childResult.animationName).toBeDefined()
+ expect(childResult.animationDelay).toBe('0.01s')
+ })
+
+ it('should handle failed node lookup gracefully', async () => {
+ const node = {
+ id: 'node1',
+ type: 'FRAME',
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'missing',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockRejectedValue(new Error('Node not found'))
+
+ // Suppress console.error for this test
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ const result = await getReactionProps(node)
+
+ expect(result).toEqual({})
+
+ consoleErrorSpy.mockRestore()
+ })
+
+ it('should handle DOCUMENT or PAGE node types', async () => {
+ const node = {
+ id: 'node1',
+ type: 'FRAME',
+ opacity: 1,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'page-node',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const pageNode = {
+ id: 'page-node',
+ type: 'PAGE',
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(pageNode)
+
+ const result = await getReactionProps(node)
+
+ expect(result).toEqual({})
+ })
+
+ it('should handle animation chain error gracefully', async () => {
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node3',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockImplementation(async (id: string) => {
+ if (id === 'node2') return node2
+ if (id === 'node3') throw new Error('Failed to get node3')
+ return null
+ })
+
+ // Suppress console.error for this test
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ const result = await getReactionProps(node1)
+
expect(result.animationName).toBeDefined()
expect(result.animationDuration).toBe('0.5s')
+
+ consoleErrorSpy.mockRestore()
+ })
+
+ it('should match children by name with loop and delay', async () => {
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1',
+ name: 'Button',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0.02,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-new',
+ name: 'Button',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'parent',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ await getReactionProps(parentNode)
+
+ const childNode = parentNode.children[0]
+ const result = await getReactionProps(childNode)
+
expect(result.animationIterationCount).toBe('infinite')
+ expect(result.animationDelay).toBe('0.02s')
+ })
+
+ it('should handle children with no matching nodes in some steps', async () => {
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1',
+ name: 'Button',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ // Destination has no children named 'Button'
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child2',
+ name: 'Text',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ const result = await getReactionProps(parentNode)
+
+ expect(result).toEqual({})
+ })
+
+ it('should handle parent and child nodes without children prop', async () => {
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ opacity: 1,
+ // No children property
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ opacity: 0.5,
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ const result = await getReactionProps(parentNode)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationDuration).toBe('0.5s')
+ })
+
+ it('should return empty when child not found in cache', async () => {
+ const childWithNoCache = {
+ id: 'child-no-cache',
+ name: 'NoCache',
+ type: 'FRAME',
+ parent: { id: 'non-existent-parent' },
+ } as any
+
+ const result = await getReactionProps(childWithNoCache)
+
+ expect(result).toEqual({})
+ })
+
+ it('should handle node with direct self-loop (currentNodeId === startNode.id)', async () => {
+ const loopNode = {
+ id: 'loop-node',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ opacity: 1,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'loop-node',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(loopNode)
+
+ const result = await getReactionProps(loopNode)
+
+ // Direct self-loop should result in empty (isLoop: true but chain is empty)
+ expect(result).toEqual({})
+ })
+
+ it('should handle chain with nested loop detection (isLoop propagation)', async () => {
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ opacity: 1,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ opacity: 0.8,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node3',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node3 = {
+ id: 'node3',
+ type: 'FRAME',
+ x: 200,
+ y: 0,
+ opacity: 0.5,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node1',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockImplementation(async (id: string) => {
+ if (id === 'node2') return node2
+ if (id === 'node3') return node3
+ return null
+ })
+
+ const result = await getReactionProps(node1)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationIterationCount).toBe('infinite')
+ // 0.5 + 0.5 for chain + 0.5 for return-to-initial = 1.5s
+ expect(result.animationDuration).toBe('1.5s')
+ // Keyframes should include 100% returning to initial state
+ const animationName = result.animationName as string
+ expect(animationName).toContain('100%')
+ })
+
+ it('should handle visited node in recursive chain (line 284)', async () => {
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ opacity: 1,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ {
+ type: 'NODE',
+ destinationId: 'node3',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ x: 50,
+ y: 0,
+ opacity: 0.8,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node3',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node3 = {
+ id: 'node3',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ opacity: 0.5,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockImplementation(async (id: string) => {
+ if (id === 'node2') return node2
+ if (id === 'node3') return node3
+ return null
+ })
+
+ const result = await getReactionProps(node1)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationDuration).toBe('0.6s')
+ })
+
+ it('should handle children without matching in prev/current nodes (line 413)', async () => {
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1',
+ name: 'Button',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ // Dest has no children property
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ const result = await getReactionProps(parentNode)
+
+ expect(result).toEqual({})
+ })
+
+ it('should handle firstChild missing in animation (line 465-466)', async () => {
+ const child1 = {
+ id: 'child1',
+ name: 'Button',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ opacity: 1,
+ parent: { id: 'parent' },
+ } as any
+
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [child1],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest',
+ name: 'Button',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ opacity: 0.5,
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode2 = {
+ id: 'dest2',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest2',
+ name: 'Button',
+ type: 'FRAME',
+ x: 200,
+ y: 0,
+ opacity: 1,
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockImplementation(async (id: string) => {
+ if (id === 'dest') return destNode
+ if (id === 'dest2') return destNode2
+ return null
+ })
+
+ await getReactionProps(parentNode)
+
+ const result = await getReactionProps(child1)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationDuration).toBe('1s')
+ })
+
+ it('should return cached child animation when cache exists for child name (line 41-43)', async () => {
+ const child1 = {
+ id: 'child1',
+ name: 'AnimatedButton',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ } as any
+
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [child1],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.8,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0.03,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest',
+ name: 'AnimatedButton',
+ type: 'FRAME',
+ x: 150,
+ y: 50,
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ await getReactionProps(parentNode)
+
+ const result = await getReactionProps(child1)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationDuration).toBe('0.8s')
+ expect(result.animationDelay).toBe('0.03s')
+ })
+
+ it('should handle when parent cache exists but child name not in cache (line 43)', async () => {
+ const child1 = {
+ id: 'child1',
+ name: 'CachedButton',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ } as any
+
+ const child2 = {
+ id: 'child2',
+ name: 'UncachedButton',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ parent: { id: 'parent' },
+ } as any
+
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [child1],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest',
+ name: 'CachedButton',
+ type: 'FRAME',
+ x: 100,
+ y: 0,
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ await getReactionProps(parentNode)
+
+ const result = await getReactionProps(child2)
+
+ expect(result).toEqual({})
+ })
+
+ it('should handle rotation animation with cumulative rotation in loop (lines 188-193, 201, 290-295)', async () => {
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: 0,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: 90, // Rotated 90 degrees
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node1', // Loop back
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(node2)
+
+ const result = await getReactionProps(node1)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationIterationCount).toBe('infinite')
+ // Should contain rotate in keyframes
+ const animationName = result.animationName as string
+ expect(animationName).toContain('rotate')
+ expect(animationName).toContain('100%')
+ })
+
+ it('should handle child rotation animation (lines 484, 505-506, 540-542, 553, 565-566, 607-612, 641-646)', async () => {
+ const child1 = {
+ id: 'child1',
+ name: 'RotatingChild',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: 0,
+ parent: { id: 'parent' },
+ } as any
+
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [child1],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode = {
+ id: 'dest',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest',
+ name: 'RotatingChild',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: 45,
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'parent', // Loop back
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(destNode)
+
+ await getReactionProps(parentNode)
+
+ const result = await getReactionProps(child1)
+
+ expect(result.animationName).toBeDefined()
+ expect(result.animationIterationCount).toBe('infinite')
+ const animationName = result.animationName as string
+ expect(animationName).toContain('rotate')
+ })
+
+ it('should handle rotation delta greater than 180 degrees (line 758)', async () => {
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ rotation: 170,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ // Rotation from 170 to -170 should take the short path (+20 degrees)
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ rotation: -170,
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(node2)
+
+ const result = await getReactionProps(node1)
+
+ expect(result.animationName).toBeDefined()
+ const animationName = result.animationName as string
+ expect(animationName).toContain('rotate')
+ // The delta should be normalized (170 to -170 = -340, normalized to +20)
+ })
+
+ it('should handle rotation with multi-step chain (covers cumulative rotation)', async () => {
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ rotation: 0,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ rotation: 90,
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node3',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node3 = {
+ id: 'node3',
+ type: 'FRAME',
+ rotation: 180,
+ } as any
+
+ mockGetNodeByIdAsync.mockImplementation(async (id: string) => {
+ if (id === 'node2') return node2
+ if (id === 'node3') return node3
+ return null
+ })
+
+ const result = await getReactionProps(node1)
+
+ expect(result.animationName).toBeDefined()
+ const animationName = result.animationName as string
+ expect(animationName).toContain('rotate')
+ // Should have intermediate keyframes
+ expect(animationName).toContain('50%')
+ expect(animationName).toContain('100%')
+ })
+
+ it('should set rotate(0deg) when hasRotationAnimation but no initialValues.transform (line 201)', async () => {
+ // This test covers the case where rotation animation exists but
+ // the starting transform value is not in startingValues
+ const node1 = {
+ id: 'node1',
+ type: 'FRAME',
+ rotation: 0, // Starting at 0
+ // No x, y changes, only rotation
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'node2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.5,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const node2 = {
+ id: 'node2',
+ type: 'FRAME',
+ rotation: 45, // Only rotation changes
+ } as any
+
+ mockGetNodeByIdAsync.mockResolvedValue(node2)
+
+ const result = await getReactionProps(node1)
+
+ expect(result.animationName).toBeDefined()
+ const animationName = result.animationName as string
+ // Should contain rotate(0deg) in initial keyframe
+ expect(animationName).toContain('rotate(0deg)')
+ expect(animationName).toContain('rotate(-45deg)')
+ })
+
+ it('should handle child rotation with starting values from startChild (lines 538-542)', async () => {
+ const child1 = {
+ id: 'child1',
+ name: 'RotatingIcon',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: 0,
+ parent: { id: 'parent' },
+ } as any
+
+ const parentNode = {
+ id: 'parent',
+ type: 'FRAME',
+ children: [child1],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest1',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode1 = {
+ id: 'dest1',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest1',
+ name: 'RotatingIcon',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: 120,
+ },
+ ],
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ destinationId: 'dest2',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ },
+ },
+ ],
+ trigger: {
+ type: 'AFTER_TIMEOUT',
+ timeout: 0,
+ },
+ },
+ ],
+ } as any
+
+ const destNode2 = {
+ id: 'dest2',
+ type: 'FRAME',
+ children: [
+ {
+ id: 'child1-dest2',
+ name: 'RotatingIcon',
+ type: 'FRAME',
+ x: 0,
+ y: 0,
+ rotation: -120,
+ },
+ ],
+ } as any
+
+ mockGetNodeByIdAsync.mockImplementation(async (id: string) => {
+ if (id === 'dest1') return destNode1
+ if (id === 'dest2') return destNode2
+ return null
+ })
+
+ await getReactionProps(parentNode)
+
+ const result = await getReactionProps(child1)
+
+ expect(result.animationName).toBeDefined()
+ const animationName = result.animationName as string
+ expect(animationName).toContain('rotate')
})
})
diff --git a/src/codegen/props/__tests__/selector.test.ts b/src/codegen/props/__tests__/selector.test.ts
new file mode 100644
index 0000000..8455785
--- /dev/null
+++ b/src/codegen/props/__tests__/selector.test.ts
@@ -0,0 +1,396 @@
+import { describe, expect, test } from 'bun:test'
+import { getSelectorProps } from '../selector'
+
+// Mock figma global
+;(globalThis as { figma?: unknown }).figma = {
+ mixed: Symbol('mixed'),
+ util: {
+ rgba: (color: { r: number; g: number; b: number; a?: number }) => ({
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ a: color.a ?? 1,
+ }),
+ },
+} as unknown as typeof figma
+
+describe('getSelectorProps', () => {
+ test('returns undefined for non-COMPONENT_SET node', async () => {
+ const node = {
+ type: 'FRAME',
+ name: 'NotComponentSet',
+ children: [],
+ visible: true,
+ } as unknown as FrameNode
+
+ const result = await getSelectorProps(node as unknown as ComponentSetNode)
+ expect(result).toBeUndefined()
+ })
+
+ test('handles componentPropertyDefinitions with effect property', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'State=Default, effect=default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { State: 'Default', effect: 'default' },
+ } as unknown as ComponentNode
+
+ const hoverVariant = {
+ type: 'COMPONENT',
+ name: 'State=Hover, effect=hover',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { State: 'Hover', effect: 'hover' },
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0.5, b: 1 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'EffectButton',
+ children: [defaultVariant, hoverVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ State: {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Hover'],
+ },
+ effect: {
+ type: 'VARIANT',
+ variantOptions: ['default', 'hover'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+ expect(result).toBeDefined()
+ // effect should not be in variants (it's treated specially)
+ expect(result?.variants.effect).toBeUndefined()
+ expect(result?.variants.State).toBe("'Default' | 'Hover'")
+ })
+
+ test('handles COMPONENT node with COMPONENT_SET parent', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'State=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ } as unknown as ComponentNode
+
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'TestSet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ State: {
+ type: 'VARIANT',
+ variantOptions: ['Default'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ // Set parent relationship
+ ;(defaultVariant as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+
+ const result = await getSelectorProps(defaultVariant)
+ expect(result).toBeDefined()
+ expect(result?.variants.State).toBe("'Default'")
+ })
+
+ describe('sanitizePropertyName', () => {
+ test('returns variant for numeric-only property name', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: '123=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { '123': 'Default' },
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'NumericPropertySet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ '123': {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Active'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+
+ expect(result).toBeDefined()
+ expect(result?.variants).toBeDefined()
+ // Numeric property name '123' is prefixed with _ to become '_123'
+ expect(result?.variants._123).toBe("'Default' | 'Active'")
+ })
+
+ test('returns variant for empty property name after cleaning', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: '한글=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { 한글: 'Default' },
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'KoreanPropertySet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ 한글: {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Active'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+
+ expect(result).toBeDefined()
+ expect(result?.variants).toBeDefined()
+ // Korean property name should be sanitized to 'variant' (empty after cleaning)
+ expect(result?.variants.variant).toBe("'Default' | 'Active'")
+ })
+
+ test('converts property name with spaces and special chars to camelCase', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'my-prop_name test=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { 'my-prop_name test': 'Default' },
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'CamelCasePropertySet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ 'my-prop_name test': {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Active'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+
+ expect(result).toBeDefined()
+ expect(result?.variants).toBeDefined()
+ // Property name with special chars should be converted to camelCase
+ expect(result?.variants.myPropNameTest).toBe("'Default' | 'Active'")
+ })
+
+ test('sanitizes property name that starts with digit', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: '1abc=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { '1abc': 'Default' },
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'DigitStartPropertySet',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ '1abc': {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Active'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+
+ expect(result).toBeDefined()
+ expect(result?.variants).toBeDefined()
+ // Property name starting with digit should be prefixed with _
+ expect(result?.variants._1abc).toBe("'Default' | 'Active'")
+ })
+ })
+
+ describe('triggerTypeToEffect', () => {
+ test('handles ON_HOVER trigger type', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'State=Default',
+ children: [],
+ visible: true,
+ reactions: [{ trigger: { type: 'ON_HOVER' } }],
+ } as unknown as ComponentNode
+
+ const hoverVariant = {
+ type: 'COMPONENT',
+ name: 'State=Hover',
+ children: [],
+ visible: true,
+ reactions: [{ trigger: { type: 'ON_HOVER' } }],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0.5, b: 1 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'HoverButton',
+ children: [defaultVariant, hoverVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ State: {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Hover'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+ expect(result).toBeDefined()
+ // Should have _hover props when triggerType is ON_HOVER
+ expect(result?.props._hover).toBeDefined()
+ })
+
+ test('handles ON_PRESS trigger type', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'State=Default',
+ children: [],
+ visible: true,
+ reactions: [{ trigger: { type: 'ON_PRESS' } }],
+ } as unknown as ComponentNode
+
+ const activeVariant = {
+ type: 'COMPONENT',
+ name: 'State=Active',
+ children: [],
+ visible: true,
+ reactions: [{ trigger: { type: 'ON_PRESS' } }],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'ActiveButton',
+ children: [defaultVariant, activeVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ State: {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Active'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+ expect(result).toBeDefined()
+ // Should have _active props when triggerType is ON_PRESS
+ expect(result?.props._active).toBeDefined()
+ })
+
+ test('handles SMART_ANIMATE transition with reactions', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'State=Default',
+ children: [],
+ visible: true,
+ reactions: [
+ {
+ trigger: { type: 'ON_HOVER' },
+ actions: [
+ {
+ type: 'NODE',
+ transition: {
+ type: 'SMART_ANIMATE',
+ duration: 0.3,
+ easing: { type: 'EASE_IN_AND_OUT' },
+ },
+ },
+ ],
+ },
+ ],
+ } as unknown as ComponentNode
+
+ const hoverVariant = {
+ type: 'COMPONENT',
+ name: 'State=Hover',
+ children: [],
+ visible: true,
+ reactions: [{ trigger: { type: 'ON_HOVER' } }],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0.5, b: 1 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'AnimatedButton',
+ children: [defaultVariant, hoverVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ State: {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Hover'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+ expect(result).toBeDefined()
+ expect(result?.props._hover).toBeDefined()
+ // Should have transition props when SMART_ANIMATE is used
+ expect(result?.props.transition).toBeDefined()
+ expect(result?.props.transitionProperty).toBeDefined()
+ })
+ })
+})
diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts
index b41321d..b782254 100644
--- a/src/codegen/props/auto-layout.ts
+++ b/src/codegen/props/auto-layout.ts
@@ -22,7 +22,7 @@ export function getAutoLayoutProps(
VERTICAL: 'column',
}[layoutMode],
gap:
- childrenCount > 1
+ childrenCount > 1 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN'
? addPx(node.inferredAutoLayout.itemSpacing)
: undefined,
justifyContent: getJustifyContent(node),
diff --git a/src/codegen/props/cursor.ts b/src/codegen/props/cursor.ts
new file mode 100644
index 0000000..d6c3ad4
--- /dev/null
+++ b/src/codegen/props/cursor.ts
@@ -0,0 +1,16 @@
+export function getCursorProps(node: SceneNode): Record {
+ if (!('reactions' in node) || !node.reactions) {
+ return {}
+ }
+
+ // Check if any reaction has ON_CLICK trigger
+ const hasClickTrigger = node.reactions.some(
+ (reaction) => reaction.trigger?.type === 'ON_CLICK',
+ )
+
+ if (hasClickTrigger) {
+ return { cursor: 'pointer' }
+ }
+
+ return {}
+}
diff --git a/src/codegen/props/effect.ts b/src/codegen/props/effect.ts
index e673069..30c900e 100644
--- a/src/codegen/props/effect.ts
+++ b/src/codegen/props/effect.ts
@@ -51,7 +51,6 @@ function _getEffectPropsFromEffect(effect: Effect): Record {
case 'BACKGROUND_BLUR':
return {
backdropFilter: `blur(${effect.radius}px)`,
- WebkitBackdropFilter: `blur(${effect.radius}px)`,
}
case 'NOISE':
@@ -66,7 +65,6 @@ function _getEffectPropsFromEffect(effect: Effect): Record {
case 'GLASS':
return {
backdropFilter: `blur(${effect.radius}px)`,
- WebkitBackdropFilter: `blur(${effect.radius}px)`,
}
}
}
diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts
index 82eeee3..5c056b3 100644
--- a/src/codegen/props/index.ts
+++ b/src/codegen/props/index.ts
@@ -2,6 +2,7 @@ import { getAutoLayoutProps } from './auto-layout'
import { getBackgroundProps } from './background'
import { getBlendProps } from './blend'
import { getBorderProps, getBorderRadiusProps } from './border'
+import { getCursorProps } from './cursor'
import { getEffectProps } from './effect'
import { getEllipsisProps } from './ellipsis'
import { getGridChildProps } from './grid-child'
@@ -16,12 +17,11 @@ import { getTextAlignProps } from './text-align'
import { getTextShadowProps } from './text-shadow'
import { getTextStrokeProps } from './text-stroke'
import { getTransformProps } from './transform'
+import { getVisibilityProps } from './visibility'
export async function getProps(
node: SceneNode,
-): Promise<
- Record
-> {
+): Promise> {
return {
...getAutoLayoutProps(node),
...getMinMaxProps(node),
@@ -43,6 +43,8 @@ export async function getProps(
...(await getTextStrokeProps(node)),
...(await getTextShadowProps(node)),
...(await getReactionProps(node)),
+ ...getCursorProps(node),
+ ...getVisibilityProps(node),
}
}
@@ -54,14 +56,23 @@ export function filterPropsWithComponent(
for (const [key, value] of Object.entries(props)) {
switch (component) {
case 'Flex':
+ // Only skip display/flexDir if it's exactly the default value (not responsive array)
+ if (key === 'display' && value === 'flex') continue
+ if (key === 'flexDir' && value === 'row') continue
+ break
case 'Grid':
- if (['display'].includes(key)) continue
+ // Only skip display if it's exactly 'grid' (not responsive array or other value)
+ if (key === 'display' && value === 'grid') continue
break
case 'Center':
- if (['alignItems', 'justifyContent', 'display'].includes(key)) continue
+ if (['alignItems', 'justifyContent'].includes(key)) continue
+ if (key === 'display' && value === 'flex') continue
+ if (key === 'flexDir' && value === 'row') continue
break
case 'VStack':
- if (['flexDir', 'display'].includes(key)) continue
+ // Only skip flexDir if it's exactly 'column' (not responsive array or other value)
+ if (key === 'flexDir' && value === 'column') continue
+ if (key === 'display' && value === 'flex') continue
break
case 'Image':
@@ -69,7 +80,6 @@ export function filterPropsWithComponent(
if (component === 'Box' && !('maskImage' in props)) break
if (
[
- 'display',
'alignItems',
'justifyContent',
'flexDir',
@@ -80,6 +90,7 @@ export function filterPropsWithComponent(
].includes(key)
)
continue
+ if (key === 'display' && value === 'flex') continue
if (!('maskImage' in props) && ['bg'].includes(key)) continue
break
}
diff --git a/src/codegen/props/layout.ts b/src/codegen/props/layout.ts
index e77d150..b186633 100644
--- a/src/codegen/props/layout.ts
+++ b/src/codegen/props/layout.ts
@@ -1,6 +1,8 @@
import { addPx } from '../utils/add-px'
+import { checkAssetNode } from '../utils/check-asset-node'
import { getPageNode } from '../utils/get-page-node'
import { isChildWidthShrinker } from '../utils/is-child-width-shrinker'
+import { canBeAbsolute } from './position'
export function getMinMaxProps(
node: SceneNode,
@@ -44,18 +46,22 @@ function _getTextLayoutProps(
function _getLayoutProps(
node: SceneNode,
): Record {
- if (
- 'layoutPositioning' in node &&
- node.layoutPositioning === 'ABSOLUTE' &&
- node.parent &&
- 'width' in node.parent &&
- 'height' in node.parent &&
- node.parent.width === node.width &&
- node.parent.height === node.height
- ) {
+ if (canBeAbsolute(node)) {
return {
- w: '100%',
- h: '100%',
+ w:
+ node.type === 'TEXT' ||
+ (node.parent &&
+ 'width' in node.parent &&
+ node.parent.width > node.width)
+ ? checkAssetNode(node)
+ ? addPx(node.width)
+ : undefined
+ : '100%',
+ // if node does not have children, it is a single node, so it should be 100%
+ h:
+ ('children' in node && node.children.length > 0) || node.type === 'TEXT'
+ ? undefined
+ : '100%',
}
}
const hType =
@@ -82,7 +88,9 @@ function _getLayoutProps(
? 1
: undefined,
w:
- rootNode === node && node.width === 1920
+ rootNode === node &&
+ node.width ===
+ (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode)?.width
? undefined
: wType === 'FIXED'
? addPx(node.width)
diff --git a/src/codegen/props/position.ts b/src/codegen/props/position.ts
index 6dd0363..034e4f1 100644
--- a/src/codegen/props/position.ts
+++ b/src/codegen/props/position.ts
@@ -1,6 +1,8 @@
import { addPx } from '../utils/add-px'
+import { checkAssetNode } from '../utils/check-asset-node'
+import { isPageRoot } from '../utils/is-page-root'
-function isFreelayout(node: BaseNode & ChildrenMixin) {
+export function isFreelayout(node: BaseNode & ChildrenMixin) {
return (
(!('inferredAutoLayout' in node) || !node.inferredAutoLayout) &&
'layoutPositioning' in node &&
@@ -8,17 +10,23 @@ function isFreelayout(node: BaseNode & ChildrenMixin) {
)
}
-export function getPositionProps(
- node: SceneNode,
-): Record | undefined {
- if (
+export function canBeAbsolute(node: SceneNode): boolean {
+ return !!(
'parent' in node &&
node.parent &&
(('layoutPositioning' in node && node.layoutPositioning === 'ABSOLUTE') ||
- isFreelayout(node.parent)) &&
- 'width' in node.parent &&
- 'height' in node.parent
- ) {
+ (isFreelayout(node.parent) &&
+ 'width' in node.parent &&
+ 'height' in node.parent &&
+ 'constraints' in node &&
+ !isPageRoot(node as SceneNode)))
+ )
+}
+
+export function getPositionProps(
+ node: SceneNode,
+): Record | undefined {
+ if ('parent' in node && node.parent && canBeAbsolute(node)) {
const constraints =
'constraints' in node
? node.constraints
@@ -27,40 +35,57 @@ export function getPositionProps(
'constraints' in node.children[0]
? node.children[0].constraints
: undefined
- if (!constraints) return
+ if (!constraints) {
+ if (isFreelayout(node.parent))
+ return {
+ pos: 'absolute',
+ left: addPx(node.x) ?? '0px',
+ top: addPx(node.y) ?? '0px',
+ }
+ return
+ }
const { horizontal, vertical } = constraints
+
let left: string | undefined
let right: string | undefined
let top: string | undefined
let bottom: string | undefined
- if (isFreelayout(node.parent)) {
- left = addPx(node.x) ?? '0px'
- top = addPx(node.y) ?? '0px'
- } else {
- switch (horizontal) {
- case 'MIN':
- left = addPx(node.x) ?? '0px'
- break
- case 'MAX':
- right = addPx(node.parent.width - node.x - node.width) ?? '0px'
- break
- default:
- left = '0px'
- right = '0px'
- break
- }
- switch (vertical) {
- case 'MIN':
- top = addPx(node.y) ?? '0px'
- break
- case 'MAX':
- bottom = addPx(node.parent.height - node.y - node.height) ?? '0px'
- break
- default:
- top = '0px'
- bottom = '0px'
- break
- }
+ let translateX: string | undefined
+ let translateY: string | undefined
+ switch (horizontal) {
+ case 'MIN':
+ left = addPx(node.x) ?? '0px'
+ break
+ case 'MAX':
+ right =
+ addPx((node.parent as SceneNode).width - node.x - node.width) ?? '0px'
+ break
+ case 'CENTER':
+ left = '50%'
+ translateX = '50%'
+ break
+ default:
+ left = '0px'
+ right = '0px'
+ break
+ }
+ switch (vertical) {
+ case 'MIN':
+ top = addPx(node.y) ?? '0px'
+ break
+ case 'MAX':
+ bottom =
+ addPx((node.parent as SceneNode).height - node.y - node.height) ??
+ '0px'
+ break
+ case 'CENTER':
+ top = '50%'
+ translateY = '50%'
+ break
+ default:
+ top = '0px'
+ bottom = '0px'
+ break
}
return {
pos: 'absolute',
@@ -68,10 +93,19 @@ export function getPositionProps(
right,
top,
bottom,
+ transform:
+ translateX && translateY
+ ? `translate(-${translateX}, -${translateY})`
+ : translateX
+ ? `translateX(-${translateX})`
+ : translateY
+ ? `translateY(-${translateY})`
+ : undefined,
}
}
if (
'children' in node &&
+ !checkAssetNode(node) &&
(node.children.some(
(child) =>
'layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE',
@@ -80,7 +114,8 @@ export function getPositionProps(
node.children.some(
(child) =>
'layoutPositioning' in child && child.layoutPositioning === 'AUTO',
- )))
+ ))) &&
+ !isPageRoot(node)
) {
return {
pos: 'relative',
diff --git a/src/codegen/props/reaction.ts b/src/codegen/props/reaction.ts
index 35dbb03..7004f63 100644
--- a/src/codegen/props/reaction.ts
+++ b/src/codegen/props/reaction.ts
@@ -1,4 +1,6 @@
import { fmtPct } from '../utils/fmtPct'
+import { isPageRoot } from '../utils/is-page-root'
+import { solidToString } from '../utils/solid-to-string'
interface KeyframeData {
[percentage: string]: Record
@@ -133,17 +135,32 @@ export async function getReactionProps(
const animatedProperties = new Set()
for (const changes of allChanges) {
for (const key of Object.keys(changes)) {
- animatedProperties.add(key)
+ // rotationDelta will be converted to transform
+ if (key === 'rotationDelta') {
+ animatedProperties.add('transform')
+ } else {
+ animatedProperties.add(key)
+ }
}
}
// Get initial values for animated properties from the START node
// Only include properties that have different values across frames
const initialValues: Record = {}
+ // Check if rotation animation exists
+ const hasRotationAnimation = allChanges.some(
+ (changes) => 'rotationDelta' in changes,
+ )
+
if (allChanges.length > 0 && animatedProperties.size > 0) {
// For each property, check if it has different values
const propertyNeedsInitial = new Map()
for (const key of animatedProperties) {
+ // For transform with rotation, always needs initial value
+ if (key === 'transform' && hasRotationAnimation) {
+ propertyNeedsInitial.set(key, true)
+ continue
+ }
const values = new Set()
for (const changes of allChanges) {
if (changes[key] !== undefined) {
@@ -165,11 +182,16 @@ export async function getReactionProps(
// For each animated property, use the starting value only if it has multiple different values
for (const key of animatedProperties) {
- if (
- propertyNeedsInitial.get(key) &&
- startingValues[key] !== undefined
- ) {
- initialValues[key] = startingValues[key]
+ if (propertyNeedsInitial.get(key)) {
+ if (startingValues[key] !== undefined) {
+ initialValues[key] = startingValues[key]
+ } else if (
+ key === 'transform' &&
+ hasRotationAnimation
+ ) {
+ // For rotation animation, start at 0deg
+ initialValues[key] = 'rotate(0deg)'
+ }
}
}
}
@@ -181,6 +203,11 @@ export async function getReactionProps(
// Exclude properties that appear multiple times with the same value
const propertyHasMultipleValues = new Map()
for (const key of animatedProperties) {
+ // For transform with rotation, always include
+ if (key === 'transform' && hasRotationAnimation) {
+ propertyHasMultipleValues.set(key, true)
+ continue
+ }
const values = new Set()
let occurrenceCount = 0
for (const changes of allChanges) {
@@ -197,20 +224,38 @@ export async function getReactionProps(
}
// Second pass: build keyframes with incremental changes
+ // For loop animations, we need to add one more step to return to initial state
+ const effectiveTotalDuration = isLoop
+ ? totalDuration + animationChain[0].duration
+ : totalDuration
+
let accumulatedTime = 0
let previousKeyframe: Record = {
...initialValues,
}
+ let cumulativeRotation = 0 // Track cumulative rotation
+
for (let i = 0; i < animationChain.length; i++) {
const step = animationChain[i]
accumulatedTime += step.duration
const percentage = Math.round(
- (accumulatedTime / totalDuration) * 100,
+ (accumulatedTime / effectiveTotalDuration) * 100,
)
const percentageKey = `${percentage}%`
- const changes = allChanges[i]
+ const changes = { ...allChanges[i] }
+
+ // Convert rotationDelta to cumulative transform
+ if ('rotationDelta' in changes) {
+ cumulativeRotation += changes.rotationDelta as number
+ const existingTransform =
+ (changes.transform as string) || ''
+ changes.transform = existingTransform
+ ? `${existingTransform} rotate(${fmtPct(cumulativeRotation)}deg)`
+ : `rotate(${fmtPct(cumulativeRotation)}deg)`
+ delete changes.rotationDelta
+ }
// Only include properties that changed from previous keyframe AND have multiple values
const incrementalChanges: Record = {}
@@ -232,10 +277,25 @@ export async function getReactionProps(
}
}
+ // For loop animations, add 100% keyframe that returns to initial state
+ if (isLoop && Object.keys(keyframes).length > 1) {
+ const finalKeyframe = { ...initialValues }
+ // If there was rotation, complete the full rotation cycle
+ if (cumulativeRotation !== 0) {
+ const fullRotation =
+ Math.sign(cumulativeRotation) *
+ Math.ceil(Math.abs(cumulativeRotation) / 360) *
+ 360
+ // Replace the rotation value entirely (don't append to existing rotate(0deg))
+ finalKeyframe.transform = `rotate(${fmtPct(fullRotation)}deg)`
+ }
+ keyframes['100%'] = finalKeyframe
+ }
+
if (Object.keys(keyframes).length > 1) {
const props: Record = {
animationName: `keyframes(${JSON.stringify(keyframes)})`,
- animationDuration: `${fmtDuration(totalDuration)}s`,
+ animationDuration: `${fmtDuration(effectiveTotalDuration)}s`,
animationTimingFunction: getEasingFunction(firstEasing),
animationFillMode: 'forwards',
}
@@ -279,11 +339,6 @@ async function buildAnimationChain(
const currentNodeId = currentNode.id
let isLoop = false
- // Prevent infinite loops by checking if we've visited this node
- if (visitedIds.has(currentNodeId)) {
- return { chain, isLoop: false }
- }
-
// Check for circular reference back to start node (this means it's a loop!)
if (currentNodeId === startNode.id) {
return { chain, isLoop: true }
@@ -419,17 +474,32 @@ async function generateChildAnimations(
const animatedProperties = new Set()
for (const changes of allChanges) {
for (const key of Object.keys(changes)) {
- animatedProperties.add(key)
+ // rotationDelta will be converted to transform
+ if (key === 'rotationDelta') {
+ animatedProperties.add('transform')
+ } else {
+ animatedProperties.add(key)
+ }
}
}
// Get initial values for animated properties from the START state
// Only include properties that have different values across frames
const initialValues: Record = {}
+ // Check if rotation animation exists
+ const hasRotationAnimation = allChanges.some(
+ (changes) => 'rotationDelta' in changes,
+ )
+
if (allChanges.length > 0 && animatedProperties.size > 0) {
// For each property, check if it has different values
const propertyNeedsInitial = new Map()
for (const key of animatedProperties) {
+ // For transform with rotation, always needs initial value
+ if (key === 'transform' && hasRotationAnimation) {
+ propertyNeedsInitial.set(key, true)
+ continue
+ }
const values = new Set()
for (const changes of allChanges) {
if (changes[key] !== undefined) {
@@ -459,11 +529,13 @@ async function generateChildAnimations(
// For each animated property, use the starting value only if it has multiple different values
for (const key of animatedProperties) {
- if (
- propertyNeedsInitial.get(key) &&
- startingValues[key] !== undefined
- ) {
- initialValues[key] = startingValues[key]
+ if (propertyNeedsInitial.get(key)) {
+ if (startingValues[key] !== undefined) {
+ initialValues[key] = startingValues[key]
+ } else if (key === 'transform' && hasRotationAnimation) {
+ // For rotation animation, start at 0deg
+ initialValues[key] = 'rotate(0deg)'
+ }
}
}
}
@@ -478,6 +550,11 @@ async function generateChildAnimations(
// Exclude properties that appear multiple times with the same value
const propertyHasMultipleValues = new Map()
for (const key of animatedProperties) {
+ // For transform with rotation, always include (cumulative values are always different)
+ if (key === 'transform' && hasRotationAnimation) {
+ propertyHasMultipleValues.set(key, true)
+ continue
+ }
const values = new Set()
let occurrenceCount = 0
for (const changes of allChanges) {
@@ -494,16 +571,36 @@ async function generateChildAnimations(
}
// Second pass: build keyframes with incremental changes
+ // For loop animations, we need to add one more step to return to initial state
+ // So totalDuration needs to account for this extra step
+ const effectiveTotalDuration = isLoop
+ ? totalDuration + chain[0].duration
+ : totalDuration
+
accumulatedTime = 0
let previousKeyframe: Record = { ...initialValues }
+ let cumulativeRotation = 0 // Track cumulative rotation for continuous rotation animation
+
for (let i = 0; i < chain.length; i++) {
const step = chain[i]
accumulatedTime += step.duration
- const percentage = Math.round((accumulatedTime / totalDuration) * 100)
+ const percentage = Math.round(
+ (accumulatedTime / effectiveTotalDuration) * 100,
+ )
const percentageKey = `${percentage}%`
- const changes = allChanges[i]
+ const changes = { ...allChanges[i] }
+
+ // Convert rotationDelta to cumulative transform
+ if ('rotationDelta' in changes) {
+ cumulativeRotation += changes.rotationDelta as number
+ const existingTransform = (changes.transform as string) || ''
+ changes.transform = existingTransform
+ ? `${existingTransform} rotate(${fmtPct(cumulativeRotation)}deg)`
+ : `rotate(${fmtPct(cumulativeRotation)}deg)`
+ delete changes.rotationDelta
+ }
// Only include properties that changed from previous keyframe AND have multiple values
const incrementalChanges: Record = {}
@@ -523,6 +620,24 @@ async function generateChildAnimations(
}
}
+ // For loop animations, add 100% keyframe that returns to initial state
+ // For rotation, calculate what the final rotation should be to complete the loop
+ if (isLoop && hasChanges) {
+ const finalKeyframe = { ...initialValues }
+ // If there was rotation, complete the full rotation cycle
+ if (cumulativeRotation !== 0) {
+ // Calculate the final rotation to complete a full cycle back to 0
+ // e.g., if we rotated 270deg (3 steps of 90deg), final should be 360deg
+ const fullRotation =
+ Math.sign(cumulativeRotation) *
+ Math.ceil(Math.abs(cumulativeRotation) / 360) *
+ 360
+ // Replace the rotation value entirely (don't append to existing rotate(0deg))
+ finalKeyframe.transform = `rotate(${fmtPct(fullRotation)}deg)`
+ }
+ keyframes['100%'] = finalKeyframe
+ }
+
// If this child has changes, add animation props
if (hasChanges && Object.keys(keyframes).length > 1) {
const firstEasing = firstStep.easing || { type: 'LINEAR' }
@@ -530,7 +645,7 @@ async function generateChildAnimations(
const props: Record = {
animationName: `keyframes(${JSON.stringify(keyframes)})`,
- animationDuration: `${fmtDuration(totalDuration)}s`,
+ animationDuration: `${fmtDuration(effectiveTotalDuration)}s`,
animationTimingFunction: getEasingFunction(firstEasing),
animationFillMode: 'forwards',
}
@@ -559,7 +674,13 @@ async function generateSingleNodeDifferences(
const changes: Record = {}
// Check position changes
- if ('x' in fromNode && 'x' in toNode && 'y' in fromNode && 'y' in toNode) {
+ if (
+ !isPageRoot(toNode.parent as BaseNode) &&
+ 'x' in fromNode &&
+ 'x' in toNode &&
+ 'y' in fromNode &&
+ 'y' in toNode
+ ) {
if (fromNode.x !== toNode.x || fromNode.y !== toNode.y) {
const deltaX = toNode.x - fromNode.x
const deltaY = toNode.y - fromNode.y
@@ -609,18 +730,28 @@ async function generateSingleNodeDifferences(
toFill.type === 'SOLID' &&
!isSameColor(fromFill.color, toFill.color)
) {
- changes.bg = rgbToString(toFill.color, toFill.opacity)
+ changes.bg = await solidToString(toFill)
}
}
}
// Check rotation changes
+ // Store rotation delta instead of absolute value to support cumulative rotation
if ('rotation' in fromNode && 'rotation' in toNode) {
if (fromNode.rotation !== toNode.rotation) {
- const existingTransform = (changes.transform as string) || ''
- changes.transform = existingTransform
- ? `${existingTransform} rotate(${fmtPct(toNode.rotation)}deg)`
- : `rotate(${fmtPct(toNode.rotation)}deg)`
+ // Calculate the shortest rotation delta, considering Figma's -180 to 180 range
+ let delta = toNode.rotation - fromNode.rotation
+
+ // Normalize delta to handle wrap-around (e.g., 170 to -170 should be +20, not -340)
+ // If absolute delta > 180, it means we crossed the -180/180 boundary
+ if (delta > 180) {
+ delta -= 360
+ } else if (delta < -180) {
+ delta += 360
+ }
+
+ // Store as rotationDelta for cumulative calculation in keyframe building
+ changes.rotationDelta = -delta // Negate because Figma rotation is clockwise-negative
}
}
@@ -649,15 +780,3 @@ function isSameColor(color1: RGB, color2: RGB): boolean {
Math.abs(color1.b - color2.b) < 0.01
)
}
-
-function rgbToString(color: RGB, opacity?: number): string {
- const r = Math.round(color.r * 255)
- const g = Math.round(color.g * 255)
- const b = Math.round(color.b * 255)
-
- if (opacity !== undefined && opacity < 1) {
- return `rgba(${r}, ${g}, ${b}, ${opacity})`
- }
-
- return `rgb(${r}, ${g}, ${b})`
-}
diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts
index becc1e3..f38da9f 100644
--- a/src/codegen/props/selector.ts
+++ b/src/codegen/props/selector.ts
@@ -1,6 +1,29 @@
import { fmtPct } from '../utils/fmtPct'
import { getProps } from '.'
+// 속성 이름을 유효한 TypeScript 식별자로 변환
+const toUpperCase = (_: string, chr: string) => chr.toUpperCase()
+
+function sanitizePropertyName(name: string): string {
+ // 1. 공백과 특수문자를 처리하여 camelCase로 변환
+ const result = name
+ .trim()
+ // 공백이나 특수문자 뒤의 문자를 대문자로 (camelCase 변환)
+ .replace(/[\s\-_]+(.)/g, toUpperCase)
+ // 숫자로 시작하면 앞에 _ 추가
+ .replace(/^(\d)/, '_$1')
+
+ // 2. 유효하지 않은 문자 제거 (한글, 특수문자 등)
+ const cleaned = result.replace(/[^\w$]/g, '')
+
+ // 3. 완전히 비어있거나 숫자로만 구성된 경우 기본값 사용
+ if (!cleaned || /^\d+$/.test(cleaned)) {
+ return 'variant'
+ }
+
+ return cleaned
+}
+
export async function getSelectorProps(
node: ComponentSetNode | ComponentNode,
): Promise<
@@ -36,7 +59,12 @@ export async function getSelectorProps(
const result = Object.entries(node.componentPropertyDefinitions).reduce(
(acc, [name, definition]) => {
if (name !== 'effect') {
- acc.variants[name] = definition.variantOptions?.join(' | ') || ''
+ const sanitizedName = sanitizePropertyName(name)
+ // variant 옵션값들을 문자열 리터럴로 감싸기
+ acc.variants[sanitizedName] =
+ definition.variantOptions
+ ?.map((option) => `'${option}'`)
+ .join(' | ') || ''
}
return acc
},
@@ -47,12 +75,11 @@ export async function getSelectorProps(
)
if (components.length > 0) {
+ const findNodeAction = (action: Action) => action.type === 'NODE'
+ const getTransition = (reaction: Reaction) =>
+ reaction.actions?.find(findNodeAction)?.transition
const transition = node.defaultVariant.reactions
- .flatMap(
- (reaction) =>
- reaction.actions?.find((action) => action.type === 'NODE')
- ?.transition,
- )
+ .flatMap(getTransition)
.flat()[0]
const diffKeys = new Set()
for (const [effect, props] of components) {
diff --git a/src/codegen/props/transform.ts b/src/codegen/props/transform.ts
index f285639..e8c4190 100644
--- a/src/codegen/props/transform.ts
+++ b/src/codegen/props/transform.ts
@@ -1,10 +1,12 @@
import { fmtPct } from '../utils/fmtPct'
+import { canBeAbsolute } from './position'
export function getTransformProps(
node: SceneNode,
): Record | undefined {
- if ('rotation' in node && node.rotation !== 0)
+ if ('rotation' in node && Math.abs(node.rotation) > 0.01)
return {
- transform: `rotate(${fmtPct(node.rotation)}deg)`,
+ transform: `rotate(${fmtPct(-node.rotation)}deg)`,
+ transformOrigin: canBeAbsolute(node) ? 'top left' : undefined,
}
}
diff --git a/src/codegen/props/visibility.ts b/src/codegen/props/visibility.ts
new file mode 100644
index 0000000..fa5f4a0
--- /dev/null
+++ b/src/codegen/props/visibility.ts
@@ -0,0 +1,9 @@
+export function getVisibilityProps(
+ node: SceneNode,
+): Record | undefined {
+ return node.visible
+ ? undefined
+ : {
+ display: 'none',
+ }
+}
diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts
index f6d56cd..47c21e5 100644
--- a/src/codegen/render/index.ts
+++ b/src/codegen/render/index.ts
@@ -6,7 +6,7 @@ import { propsToString } from '../utils/props-to-str'
export function renderNode(
component: string,
- props: Record,
+ props: Record,
deps: number = 0,
childrenCodes: string[],
): string {
diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts
index e04689c..5c4d472 100644
--- a/src/codegen/responsive/ResponsiveCodegen.ts
+++ b/src/codegen/responsive/ResponsiveCodegen.ts
@@ -1,28 +1,16 @@
-import { getProps } from '../props'
+import { Codegen } from '../Codegen'
import { renderNode } from '../render'
-import { getDevupComponentByNode } from '../utils/get-devup-component'
+import type { NodeTree, Props } from '../types'
import {
- BREAKPOINT_ORDER,
+ BREAKPOINT_INDEX,
type BreakpointKey,
getBreakpointByWidth,
mergePropsToResponsive,
- optimizeResponsiveValue,
} from './index'
-type PropValue = boolean | string | number | undefined | null | object
-type Props = Record
-
-interface NodePropsMap {
- breakpoint: BreakpointKey
- props: Props
- children: Map
- nodeType: string
- nodeName: string
- node: SceneNode
-}
-
/**
* Generate responsive code by merging children inside a Section.
+ * Uses Codegen to build NodeTree for each breakpoint, then merges them.
*/
export class ResponsiveCodegen {
private breakpointNodes: Map = new Map()
@@ -46,36 +34,6 @@ export class ResponsiveCodegen {
}
}
- /**
- * Recursively extract props and children from a node.
- * Reuses getProps.
- */
- private async extractNodeProps(
- node: SceneNode,
- breakpoint: BreakpointKey,
- ): Promise {
- const props = await getProps(node)
- const children = new Map()
-
- if ('children' in node) {
- for (const child of node.children) {
- const childProps = await this.extractNodeProps(child, breakpoint)
- const existing = children.get(child.name) || []
- existing.push(childProps)
- children.set(child.name, existing)
- }
- }
-
- return {
- breakpoint,
- props,
- children,
- nodeType: node.type,
- nodeName: node.name,
- node,
- }
- }
-
/**
* Generate responsive code.
*/
@@ -85,61 +43,140 @@ export class ResponsiveCodegen {
}
if (this.breakpointNodes.size === 1) {
- // If only one breakpoint, generate normal code (reuse existing path).
+ // If only one breakpoint, generate normal code using Codegen.
const [, node] = [...this.breakpointNodes.entries()][0]
- return await this.generateNodeCode(node, 0)
+ const codegen = new Codegen(node)
+ const tree = await codegen.getTree()
+ return Codegen.renderTree(tree, 0)
}
- // Extract props per breakpoint node.
- const breakpointNodeProps = new Map()
+ // Extract trees per breakpoint using Codegen.
+ const breakpointTrees = new Map()
for (const [bp, node] of this.breakpointNodes) {
- const nodeProps = await this.extractNodeProps(node, bp)
- breakpointNodeProps.set(bp, nodeProps)
+ const codegen = new Codegen(node)
+ const tree = await codegen.getTree()
+ breakpointTrees.set(bp, tree)
}
- // Merge responsively and generate code.
- return await this.generateMergedCode(breakpointNodeProps, 0)
+ // Merge trees and generate code.
+ return this.generateMergedCode(breakpointTrees, 0)
+ }
+
+ /**
+ * Convert NodeTree children array to Map by nodeName.
+ */
+ private treeChildrenToMap(tree: NodeTree): Map {
+ const result = new Map()
+ for (const child of tree.children) {
+ const existing = result.get(child.nodeName) || []
+ existing.push(child)
+ result.set(child.nodeName, existing)
+ }
+ return result
}
/**
- * Generate merged responsive code.
- * Reuses renderNode.
+ * Generate merged responsive code from NodeTree objects.
*/
- private async generateMergedCode(
- nodesByBreakpoint: Map,
+ private generateMergedCode(
+ treesByBreakpoint: Map,
depth: number,
- ): Promise {
- // Merge props.
+ ): string {
+ const firstTree = [...treesByBreakpoint.values()][0]
+
+ // If node is INSTANCE or COMPONENT, render as component reference
+ if (firstTree.isComponent) {
+ // For components, we might still need position props
+ const propsMap = new Map()
+ for (const [bp, tree] of treesByBreakpoint) {
+ const posProps: Props = {}
+ if (tree.props.pos) posProps.pos = tree.props.pos
+ if (tree.props.top) posProps.top = tree.props.top
+ if (tree.props.left) posProps.left = tree.props.left
+ if (tree.props.right) posProps.right = tree.props.right
+ if (tree.props.bottom) posProps.bottom = tree.props.bottom
+ if (tree.props.display) posProps.display = tree.props.display
+ propsMap.set(bp, posProps)
+ }
+ const mergedProps = mergePropsToResponsive(propsMap)
+
+ // If component has position props, wrap in Box
+ if (Object.keys(mergedProps).length > 0) {
+ const componentCode = renderNode(firstTree.component, {}, depth + 1, [])
+ return renderNode('Box', mergedProps, depth, [componentCode])
+ }
+
+ return renderNode(firstTree.component, {}, depth, [])
+ }
+
+ // Handle WRAPPER nodes (position wrapper for components)
+ if (firstTree.nodeType === 'WRAPPER') {
+ const propsMap = new Map()
+ for (const [bp, tree] of treesByBreakpoint) {
+ propsMap.set(bp, tree.props)
+ }
+ const mergedProps = mergePropsToResponsive(propsMap)
+
+ // Recursively merge the inner component
+ const innerTrees = new Map()
+ for (const [bp, tree] of treesByBreakpoint) {
+ if (tree.children.length > 0) {
+ innerTrees.set(bp, tree.children[0])
+ }
+ }
+
+ const innerCode =
+ innerTrees.size > 0
+ ? this.generateMergedCode(innerTrees, depth + 1)
+ : ''
+
+ return renderNode('Box', mergedProps, depth, innerCode ? [innerCode] : [])
+ }
+
+ // Merge props across breakpoints
const propsMap = new Map()
- for (const [bp, nodeProps] of nodesByBreakpoint) {
- propsMap.set(bp, nodeProps.props)
+ for (const [bp, tree] of treesByBreakpoint) {
+ propsMap.set(bp, tree.props)
}
const mergedProps = mergePropsToResponsive(propsMap)
- // Decide component type from the first node (reuse existing util).
- const firstNodeProps = [...nodesByBreakpoint.values()][0]
- const component = getDevupComponentByNode(
- firstNodeProps.node,
- firstNodeProps.props,
- )
+ // Handle TEXT nodes with textChildren
+ if (firstTree.textChildren && firstTree.textChildren.length > 0) {
+ // For text nodes, merge the text children
+ // Currently just using the first tree's text children
+ return renderNode(
+ firstTree.component,
+ mergedProps,
+ depth,
+ firstTree.textChildren,
+ )
+ }
- // Merge child nodes (preserve order).
+ // Merge children by name
const childrenCodes: string[] = []
const processedChildNames = new Set()
- // Base order on the first breakpoint children.
- const firstBreakpointChildren = firstNodeProps.children
+ // Convert all trees' children to maps
+ const childrenMaps = new Map>()
+ for (const [bp, tree] of treesByBreakpoint) {
+ childrenMaps.set(bp, this.treeChildrenToMap(tree))
+ }
+
+ // Get all child names in order (first tree's order, then others)
+ const firstBreakpoint = [...treesByBreakpoint.keys()][0]
+ const firstChildrenMap = childrenMaps.get(firstBreakpoint)
const allChildNames: string[] = []
- // Keep the first breakpoint child order.
- for (const name of firstBreakpointChildren.keys()) {
- allChildNames.push(name)
- processedChildNames.add(name)
+ if (firstChildrenMap) {
+ for (const name of firstChildrenMap.keys()) {
+ allChildNames.push(name)
+ processedChildNames.add(name)
+ }
}
- // Add children that exist only in other breakpoints.
- for (const nodeProps of nodesByBreakpoint.values()) {
- for (const name of nodeProps.children.keys()) {
+ // Add children that exist only in other breakpoints
+ for (const childMap of childrenMaps.values()) {
+ for (const name of childMap.keys()) {
if (!processedChildNames.has(name)) {
allChildNames.push(name)
processedChildNames.add(name)
@@ -148,11 +185,11 @@ export class ResponsiveCodegen {
}
for (const childName of allChildNames) {
- const childByBreakpoint = new Map()
+ const childByBreakpoint = new Map()
const presentBreakpoints = new Set()
- for (const [bp, nodeProps] of nodesByBreakpoint) {
- const children = nodeProps.children.get(childName)
+ for (const [bp, childMap] of childrenMaps) {
+ const children = childMap.get(childName)
if (children && children.length > 0) {
childByBreakpoint.set(bp, children[0])
presentBreakpoints.add(bp)
@@ -160,85 +197,43 @@ export class ResponsiveCodegen {
}
if (childByBreakpoint.size > 0) {
- // Add display props when a child exists only at specific breakpoints.
- if (presentBreakpoints.size < nodesByBreakpoint.size) {
- const displayProps = this.getDisplayProps(
- presentBreakpoints,
- new Set(nodesByBreakpoint.keys()),
- )
- for (const nodeProps of childByBreakpoint.values()) {
- Object.assign(nodeProps.props, displayProps)
- }
- }
-
- const childCode = await this.generateMergedCode(
- childByBreakpoint,
- depth,
+ // Add display:none props when a child exists only at specific breakpoints
+ // Find the smallest breakpoint where child exists
+ const sortedPresentBreakpoints = [...presentBreakpoints].sort(
+ (a, b) => BREAKPOINT_INDEX[a] - BREAKPOINT_INDEX[b],
)
- childrenCodes.push(childCode)
- }
- }
-
- // Reuse renderNode.
- return renderNode(component, mergedProps, depth, childrenCodes)
- }
+ const smallestPresentBp = sortedPresentBreakpoints[0]
+ const smallestPresentIdx = BREAKPOINT_INDEX[smallestPresentBp]
- /**
- * Build display props so a child shows only on present breakpoints.
- */
- private getDisplayProps(
- presentBreakpoints: Set,
- allBreakpoints: Set,
- ): Props {
- // Always use 5 slots: [mobile, sm, tablet, lg, pc]
- // If the child exists on the breakpoint: null (visible); otherwise 'none' (hidden).
- // If the Section lacks that breakpoint entirely: null.
- const displayValues: (string | null)[] = BREAKPOINT_ORDER.map((bp) => {
- if (!allBreakpoints.has(bp)) return null // Section lacks this breakpoint
- return presentBreakpoints.has(bp) ? null : 'none'
- })
-
- // If all null, return empty object.
- if (displayValues.every((v) => v === null)) {
- return {}
- }
-
- // Remove trailing nulls only (keep leading nulls).
- while (
- displayValues.length > 0 &&
- displayValues[displayValues.length - 1] === null
- ) {
- displayValues.pop()
- }
-
- // Empty array => empty object.
- if (displayValues.length === 0) {
- return {}
- }
-
- return { display: optimizeResponsiveValue(displayValues) }
- }
-
- /**
- * Generate code for a single node (fallback).
- * Reuses existing module.
- */
- private async generateNodeCode(
- node: SceneNode,
- depth: number,
- ): Promise {
- const props = await getProps(node)
- const childrenCodes: string[] = []
+ // Find the smallest breakpoint in the section
+ const sortedSectionBreakpoints = [...treesByBreakpoint.keys()].sort(
+ (a, b) => BREAKPOINT_INDEX[a] - BREAKPOINT_INDEX[b],
+ )
+ const smallestSectionBp = sortedSectionBreakpoints[0]
+ const smallestSectionIdx = BREAKPOINT_INDEX[smallestSectionBp]
+
+ // If child's smallest breakpoint is larger than section's smallest,
+ // we need to add display:none for the smaller breakpoints
+ if (smallestPresentIdx > smallestSectionIdx) {
+ // Add display:none for all breakpoints smaller than where child exists
+ for (const bp of treesByBreakpoint.keys()) {
+ if (!presentBreakpoints.has(bp)) {
+ const firstChildTree = [...childByBreakpoint.values()][0]
+ const hiddenTree: NodeTree = {
+ ...firstChildTree,
+ props: { ...firstChildTree.props, display: 'none' },
+ }
+ childByBreakpoint.set(bp, hiddenTree)
+ }
+ }
+ }
- if ('children' in node) {
- for (const child of node.children) {
- const childCode = await this.generateNodeCode(child, depth + 1)
+ const childCode = this.generateMergedCode(childByBreakpoint, depth)
childrenCodes.push(childCode)
}
}
- const component = getDevupComponentByNode(node, props)
- return renderNode(component, props, depth, childrenCodes)
+ return renderNode(firstTree.component, mergedProps, depth, childrenCodes)
}
/**
diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
index b7bf356..b23d25f 100644
--- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
+++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
-import { BREAKPOINT_ORDER, type BreakpointKey } from '../index'
+import type { NodeTree } from '../../types'
-const getPropsMock = mock(async (node: SceneNode) => ({ id: node.name }))
const renderNodeMock = mock(
(
component: string,
@@ -11,22 +10,38 @@ const renderNodeMock = mock(
) =>
`render:${component}:depth=${depth}:${JSON.stringify(props)}|${children.join(';')}`,
)
-const getDevupComponentByNodeMock = mock(() => 'Box')
+
+// Mock Codegen class
+const mockGetTree = mock(
+ async (): Promise => ({
+ component: 'Box',
+ props: { id: 'test' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'test',
+ }),
+)
+
+const mockRenderTree = mock((tree: NodeTree, depth: number) =>
+ renderNodeMock(tree.component, tree.props, depth, []),
+)
+
+const MockCodegen = class {
+ getTree = mockGetTree
+ static renderTree = mockRenderTree
+}
describe('ResponsiveCodegen', () => {
let ResponsiveCodegen: typeof import('../ResponsiveCodegen').ResponsiveCodegen
beforeEach(async () => {
- mock.module('../../props', () => ({ getProps: getPropsMock }))
mock.module('../../render', () => ({ renderNode: renderNodeMock }))
- mock.module('../../utils/get-devup-component', () => ({
- getDevupComponentByNode: getDevupComponentByNodeMock,
- }))
+ mock.module('../../Codegen', () => ({ Codegen: MockCodegen }))
;({ ResponsiveCodegen } = await import('../ResponsiveCodegen'))
- getPropsMock.mockClear()
renderNodeMock.mockClear()
- getDevupComponentByNodeMock.mockClear()
+ mockGetTree.mockClear()
+ mockRenderTree.mockClear()
})
afterEach(() => {
@@ -58,7 +73,7 @@ describe('ResponsiveCodegen', () => {
expect(result).toBe('// No responsive variants found in section')
})
- it('falls back to single breakpoint generation', async () => {
+ it('falls back to single breakpoint generation using Codegen', async () => {
const child = makeNode('mobile', 320, [makeNode('leaf', undefined, [])])
const section = {
type: 'SECTION',
@@ -66,26 +81,55 @@ describe('ResponsiveCodegen', () => {
} as unknown as SectionNode
const generator = new ResponsiveCodegen(section)
- const nodeCode = await (
- generator as unknown as {
- generateNodeCode: (node: SceneNode, depth: number) => Promise
- }
- ).generateNodeCode(child, 0)
- expect(renderNodeMock).toHaveBeenCalled()
-
const result = await generator.generateResponsiveCode()
+ expect(mockGetTree).toHaveBeenCalled()
+ expect(mockRenderTree).toHaveBeenCalled()
expect(result.startsWith('render:Box')).toBeTrue()
- expect(nodeCode.startsWith('render:Box')).toBeTrue()
})
it('merges breakpoints and adds display for missing child variants', async () => {
- const onlyMobile = makeNode('OnlyMobile')
- const sharedMobile = makeNode('Shared')
- const sharedTablet = makeNode('Shared')
+ const onlyMobileChild: NodeTree = {
+ component: 'Box',
+ props: { id: 'OnlyMobile' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'OnlyMobile',
+ }
+ const sharedChild: NodeTree = {
+ component: 'Box',
+ props: { id: 'Shared' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Shared',
+ }
- const mobileRoot = makeNode('RootMobile', 320, [onlyMobile, sharedMobile])
- const tabletRoot = makeNode('RootTablet', 1000, [sharedTablet])
+ // Mock different trees for different breakpoints
+ let callCount = 0
+ mockGetTree.mockImplementation(async () => {
+ callCount++
+ if (callCount === 1) {
+ // Mobile tree
+ return {
+ component: 'Box',
+ props: { id: 'RootMobile' },
+ children: [onlyMobileChild, sharedChild],
+ nodeType: 'FRAME',
+ nodeName: 'RootMobile',
+ }
+ }
+ // Tablet tree
+ return {
+ component: 'Box',
+ props: { id: 'RootTablet' },
+ children: [{ ...sharedChild }],
+ nodeType: 'FRAME',
+ nodeName: 'RootTablet',
+ }
+ })
+
+ const mobileRoot = makeNode('RootMobile', 320, [])
+ const tabletRoot = makeNode('RootTablet', 1000, [])
const section = {
type: 'SECTION',
children: [mobileRoot, tabletRoot],
@@ -94,46 +138,23 @@ describe('ResponsiveCodegen', () => {
const generator = new ResponsiveCodegen(section)
const result = await generator.generateResponsiveCode()
- expect(getPropsMock).toHaveBeenCalled()
+ expect(mockGetTree).toHaveBeenCalledTimes(2)
expect(renderNodeMock.mock.calls.length).toBeGreaterThan(0)
expect(result.startsWith('render:Box')).toBeTrue()
})
- it('returns empty display when all breakpoints present', async () => {
+ it('uses Codegen.renderTree for single breakpoint', async () => {
+ const child = makeNode('child', 320)
const section = {
type: 'SECTION',
- children: [makeNode('RootMobile', 320)],
+ children: [child],
} as unknown as SectionNode
- const generator = new ResponsiveCodegen(section)
- const displayProps = (
- generator as unknown as {
- getDisplayProps: (
- present: Set,
- all: Set,
- ) => Record
- }
- ).getDisplayProps(
- new Set(BREAKPOINT_ORDER),
- new Set(BREAKPOINT_ORDER),
- )
- expect(displayProps).toEqual({})
- })
- it('recursively generates node code', async () => {
- const child = makeNode('child')
- const parent = makeNode('parent', undefined, [child])
- const section = {
- type: 'SECTION',
- children: [parent],
- } as unknown as SectionNode
const generator = new ResponsiveCodegen(section)
- const nodeCode = await (
- generator as unknown as {
- generateNodeCode: (node: SceneNode, depth: number) => Promise
- }
- ).generateNodeCode(parent, 0)
- expect(nodeCode.startsWith('render:Box')).toBeTrue()
- expect(renderNodeMock).toHaveBeenCalled()
+ await generator.generateResponsiveCode()
+
+ expect(mockGetTree).toHaveBeenCalled()
+ expect(mockRenderTree).toHaveBeenCalled()
})
it('static helpers detect section and parent section', () => {
diff --git a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
index 15803ef..aaaffc7 100644
--- a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
+++ b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'bun:test'
-import { type BreakpointKey, mergePropsToResponsive } from '../index'
+import {
+ type BreakpointKey,
+ mergePropsToResponsive,
+ type Props,
+} from '../index'
describe('mergePropsToResponsive', () => {
const cases: {
@@ -95,7 +99,7 @@ describe('mergePropsToResponsive', () => {
['pc', { w: undefined }],
]),
expected: {
- w: [null, null, '10px'],
+ w: [null, null, '10px', 'initial'],
},
},
{
@@ -108,7 +112,7 @@ describe('mergePropsToResponsive', () => {
['pc', { w: undefined }],
]),
expected: {
- w: '10px',
+ w: ['10px', null, null, 'initial'],
},
},
{
@@ -144,11 +148,88 @@ describe('mergePropsToResponsive', () => {
pos: [null, null, 'absolute', null, 'initial'],
},
},
+ {
+ name: 'mobile only value with tablet and pc breakpoints needs initial at tablet position',
+ input: new Map>([
+ ['mobile', { textAlign: 'center' }],
+ ['tablet', { textAlign: undefined }],
+ ['pc', { textAlign: undefined }],
+ ]),
+ expected: {
+ textAlign: ['center', null, 'initial'],
+ },
+ },
+ {
+ name: 'display none at mobile, flex at tablet and pc should produce responsive array',
+ input: new Map>([
+ ['mobile', { display: 'none' }],
+ ['tablet', { display: 'flex' }],
+ ['pc', { display: 'flex' }],
+ ]),
+ expected: {
+ display: ['none', null, 'flex'],
+ },
+ },
+ {
+ name: 'flexDir column at mobile, row at tablet and pc should produce responsive array',
+ input: new Map>([
+ ['mobile', { flexDir: 'column' }],
+ ['tablet', { flexDir: 'row' }],
+ ['pc', { flexDir: 'row' }],
+ ]),
+ expected: {
+ flexDir: ['column', null, 'row'],
+ },
+ },
+ {
+ name: 'alignItems with default value at first should become null',
+ input: new Map>([
+ ['mobile', { alignItems: 'flex-start' }],
+ ['tablet', { alignItems: 'center' }],
+ ['pc', { alignItems: 'center' }],
+ ]),
+ expected: {
+ alignItems: [null, null, 'center'],
+ },
+ },
+ {
+ name: 'justifyContent with default value at first should become null',
+ input: new Map>([
+ ['mobile', { justifyContent: 'flex-start' }],
+ ['tablet', { justifyContent: 'center' }],
+ ['pc', { justifyContent: 'center' }],
+ ]),
+ expected: {
+ justifyContent: [null, null, 'center'],
+ },
+ },
+ {
+ name: 'flexDir with default value (row) at first should become null',
+ input: new Map>([
+ ['mobile', { flexDir: 'row' }],
+ ['tablet', { flexDir: 'column' }],
+ ['pc', { flexDir: 'column' }],
+ ]),
+ expected: {
+ flexDir: [null, null, 'column'],
+ },
+ },
+ {
+ name: 'all default values should be omitted (empty result)',
+ input: new Map>([
+ ['mobile', { alignItems: 'flex-start', justifyContent: 'flex-start' }],
+ ['tablet', { alignItems: 'flex-start', justifyContent: 'flex-start' }],
+ ['pc', { alignItems: 'flex-start', justifyContent: 'flex-start' }],
+ ]),
+ expected: {},
+ },
]
cases.forEach(({ name, input, expected }) => {
it(name, () => {
- expect(mergePropsToResponsive(input)).toEqual(expected)
+ expect(
+ mergePropsToResponsive(input as unknown as Map),
+ ).toEqual(expected as unknown as Props)
})
})
})
diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts
index 522b676..e1df774 100644
--- a/src/codegen/responsive/index.ts
+++ b/src/codegen/responsive/index.ts
@@ -1,3 +1,5 @@
+import { isDefaultProp } from '../utils/is-default-prop'
+
// Breakpoint thresholds (by width)
// Array indices: mobile=0, sm=1, tablet=2, lg=3, pc=4
// Always 5 slots
@@ -62,8 +64,16 @@ export function groupChildrenByBreakpoint(
}
type PropValue = boolean | string | number | undefined | null | object
-type Props = Record
-const SPECIAL_PROPS_WITH_INITIAL = new Set(['display', 'position', 'pos'])
+export type Props = Record
+const SPECIAL_PROPS_WITH_INITIAL = new Set([
+ 'display',
+ 'position',
+ 'pos',
+ 'transform',
+ 'w',
+ 'h',
+ 'textAlign',
+])
/**
* Compare two prop values for equality.
@@ -85,6 +95,7 @@ function isEqual(a: PropValue, b: PropValue): boolean {
* 1. If only index 0 has a value and the rest are null, return single value.
* 2. Consecutive identical values keep the first, later ones become null.
* 3. Remove trailing nulls only.
+ * 4. If the first value is default for that prop, replace with null.
*
* Examples:
* ["100px", null, null] -> "100px" (only first has value)
@@ -93,9 +104,11 @@ function isEqual(a: PropValue, b: PropValue): boolean {
* [null, null, "none"] -> [null, null, "none"] (keeps leading nulls)
* [null, null, "none", null, null] -> [null, null, "none"] (trim trailing null)
* ["100px", "200px", "200px"] -> ["100px", "200px"] (trailing equal treated as trailing null)
+ * ["flex-start", null, "center"] -> [null, null, "center"] (first value is default for alignItems)
*/
export function optimizeResponsiveValue(
arr: (PropValue | null)[],
+ key?: string,
): PropValue | (PropValue | null)[] {
const nonNullValues = arr.filter((v) => v !== null)
if (nonNullValues.length === 0) return null
@@ -115,11 +128,21 @@ export function optimizeResponsiveValue(
}
}
+ // If the first value is default for that prop, replace with null.
+ if (key && optimized[0] !== null && isDefaultProp(key, optimized[0])) {
+ optimized[0] = null
+ }
+
// Remove trailing nulls.
while (optimized.length > 0 && optimized[optimized.length - 1] === null) {
optimized.pop()
}
+ // If empty array after optimization, return null.
+ if (optimized.length === 0) {
+ return null
+ }
+
// If only index 0 has value, return single value.
if (optimized.length === 1 && optimized[0] !== null) {
return optimized[0]
@@ -133,8 +156,8 @@ export function optimizeResponsiveValue(
* Always 5 slots: [mobile, sm, tablet, lg, pc]; trailing nulls trimmed.
*/
export function mergePropsToResponsive(
- breakpointProps: Map,
-): Props {
+ breakpointProps: Map>,
+): Record {
const result: Props = {}
// If only one breakpoint, return props as-is.
@@ -160,26 +183,49 @@ export function mergePropsToResponsive(
return value ?? null
})
- // For display/position family, fill last slot with 'initial' if empty after a change.
+ // For display/position family, add 'initial' at the first EXISTING breakpoint
+ // where the value changes to null (after a non-null value).
+ // This ensures proper reset for larger breakpoints.
+ let valuesToOptimize = values
if (SPECIAL_PROPS_WITH_INITIAL.has(key)) {
- const lastNonNull = (() => {
- for (let i = values.length - 1; i >= 0; i--) {
- if (values[i] !== null) return i
+ // Find the last non-null value position in original values
+ let lastNonNullIdx = -1
+ for (let i = values.length - 1; i >= 0; i--) {
+ if (values[i] !== null) {
+ lastNonNullIdx = i
+ break
+ }
+ }
+
+ // Only need 'initial' if the last non-null is not at the end (pc)
+ if (lastNonNullIdx >= 0 && lastNonNullIdx < BREAKPOINT_ORDER.length - 1) {
+ // Find the first EXISTING breakpoint after the last non-null value
+ // that has a null/undefined value (where we need to reset)
+ let initialInsertIdx = -1
+ for (let i = lastNonNullIdx + 1; i < BREAKPOINT_ORDER.length; i++) {
+ const bp = BREAKPOINT_ORDER[i]
+ // Check if this breakpoint exists in input
+ if (breakpointProps.has(bp)) {
+ initialInsertIdx = i
+ break
+ }
+ }
+
+ // Only add 'initial' if we found a position to insert
+ if (initialInsertIdx >= 0) {
+ // Work with original values array to preserve null positions
+ const newArr = [...values]
+ newArr[initialInsertIdx] = 'initial'
+ // Trim values after initialInsertIdx (they're not needed)
+ newArr.length = initialInsertIdx + 1
+ valuesToOptimize = newArr
}
- return -1
- })()
- const lastIndex = values.length - 1
- if (
- lastNonNull >= 0 &&
- lastNonNull < lastIndex &&
- values[lastIndex] === null
- ) {
- values[lastIndex] = 'initial'
}
}
// Optimize: single when all same, otherwise array.
- const optimized = optimizeResponsiveValue(values)
+ const optimized = optimizeResponsiveValue(valuesToOptimize, key)
+
if (optimized !== null) {
result[key] = optimized
}
diff --git a/src/codegen/types.ts b/src/codegen/types.ts
new file mode 100644
index 0000000..4db4e3c
--- /dev/null
+++ b/src/codegen/types.ts
@@ -0,0 +1,17 @@
+export type Props = Record
+
+export interface NodeTree {
+ component: string // 'Flex', 'Box', 'Text', 'Image', or component name
+ props: Props
+ children: NodeTree[]
+ nodeType: string // Figma node type: 'FRAME', 'TEXT', 'INSTANCE', etc.
+ nodeName: string // Figma node name
+ isComponent?: boolean // true if this is a component reference (INSTANCE)
+ textChildren?: string[] // raw text content for TEXT nodes
+}
+
+export interface ComponentTree {
+ name: string
+ tree: NodeTree
+ variants: Record
+}
diff --git a/src/codegen/utils/__tests__/paint-to-css.test.ts b/src/codegen/utils/__tests__/paint-to-css.test.ts
index 5593607..8e781b8 100644
--- a/src/codegen/utils/__tests__/paint-to-css.test.ts
+++ b/src/codegen/utils/__tests__/paint-to-css.test.ts
@@ -571,4 +571,270 @@ describe('paintToCSS', () => {
expect(res).not.toContain('$')
expect(res).not.toContain('color-mix')
})
+
+ test('converts radial gradient with color token and full opacity (line 223)', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: mock(() =>
+ Promise.resolve({ name: 'radial-color-full' }),
+ ),
+ },
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'GRADIENT_RADIAL',
+ visible: true,
+ opacity: 1,
+ gradientTransform: [
+ [1, 0, 0.5],
+ [0, 1, 0.5],
+ ],
+ gradientStops: [
+ {
+ position: 0,
+ color: { r: 1, g: 0, b: 0, a: 1 },
+ boundVariables: { color: { id: 'var-radial' } },
+ },
+ ],
+ } as unknown as GradientPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toContain('radial-gradient')
+ expect(res).toContain('$radialColorFull')
+ expect(res).not.toContain('color-mix')
+ })
+
+ test('returns null when linear gradient is not visible', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'GRADIENT_LINEAR',
+ visible: false,
+ opacity: 1,
+ gradientTransform: [
+ [1, 0, 0],
+ [0, 1, 0],
+ ],
+ gradientStops: [
+ {
+ position: 0,
+ color: { r: 1, g: 0, b: 0, a: 1 },
+ },
+ ],
+ } as unknown as GradientPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBeNull()
+ })
+
+ test('converts image paint with FILL scaleMode', async () => {
+ const res = await paintToCSS(
+ {
+ type: 'IMAGE',
+ visible: true,
+ opacity: 1,
+ scaleMode: 'FILL',
+ } as unknown as ImagePaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('url(/icons/image.png) center/cover no-repeat')
+ })
+
+ test('converts image paint with FIT scaleMode', async () => {
+ const res = await paintToCSS(
+ {
+ type: 'IMAGE',
+ visible: true,
+ opacity: 1,
+ scaleMode: 'FIT',
+ } as unknown as ImagePaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('url(/icons/image.png) center/contain no-repeat')
+ })
+
+ test('converts image paint with CROP scaleMode', async () => {
+ const res = await paintToCSS(
+ {
+ type: 'IMAGE',
+ visible: true,
+ opacity: 1,
+ scaleMode: 'CROP',
+ } as unknown as ImagePaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('url(/icons/image.png) center/cover no-repeat')
+ })
+
+ test('converts solid paint when last is false (transparent)', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: mock(() => Promise.resolve(null)),
+ },
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'SOLID',
+ visible: true,
+ opacity: 0,
+ color: { r: 1, g: 0, b: 0 },
+ } as unknown as SolidPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('transparent')
+ })
+
+ test('converts pattern with CENTER alignment', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ getNodeByIdAsync: mock(() =>
+ Promise.resolve({ name: 'patternNode' } as unknown as SceneNode),
+ ),
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'PATTERN',
+ visible: true,
+ opacity: 1,
+ sourceNodeId: '1',
+ spacing: { x: 0.5, y: 0.5 },
+ horizontalAlignment: 'CENTER',
+ } as unknown as PatternPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toContain('url(/icons/patternNode.png)')
+ expect(res).toContain('center')
+ expect(res).toContain('repeat')
+ })
+
+ test('converts gradient with token and finalAlpha = 1 (line 222)', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: mock(() =>
+ Promise.resolve({ name: 'primary-color' }),
+ ),
+ },
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'GRADIENT_LINEAR',
+ visible: true,
+ opacity: 1,
+ gradientTransform: [
+ [1, 0, 0],
+ [0, 1, 0],
+ ],
+ gradientStops: [
+ {
+ position: 0,
+ color: { r: 1, g: 0, b: 0, a: 1 },
+ boundVariables: { color: { id: 'var-1' } },
+ },
+ ],
+ } as unknown as GradientPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toContain('$primaryColor')
+ expect(res).not.toContain('color-mix')
+ })
+
+ test('converts pattern with START alignment and zero spacing (line 288)', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ getNodeByIdAsync: mock(() =>
+ Promise.resolve({ name: 'patternStart' } as unknown as SceneNode),
+ ),
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'PATTERN',
+ visible: true,
+ opacity: 1,
+ sourceNodeId: '1',
+ spacing: { x: 0, y: 0 },
+ horizontalAlignment: 'START',
+ } as unknown as PatternPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('url(/icons/patternStart.png) repeat')
+ })
+
+ test('converts solid to linear gradient when not last and opacity > 0 (line 295-296)', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: mock(() => Promise.resolve(null)),
+ },
+ } as unknown as typeof figma
+
+ const res = await paintToCSS(
+ {
+ type: 'SOLID',
+ visible: true,
+ opacity: 0.5,
+ color: { r: 1, g: 0, b: 0 },
+ } as unknown as SolidPaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toContain('linear-gradient')
+ })
+
+ test('returns transparent when image is not visible', async () => {
+ const res = await paintToCSS(
+ {
+ type: 'IMAGE',
+ visible: false,
+ opacity: 1,
+ scaleMode: 'FILL',
+ } as unknown as ImagePaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('transparent')
+ })
+
+ test('returns transparent when image opacity is 0', async () => {
+ const res = await paintToCSS(
+ {
+ type: 'IMAGE',
+ visible: true,
+ opacity: 0,
+ scaleMode: 'FILL',
+ } as unknown as ImagePaint,
+ { width: 100, height: 100 } as unknown as SceneNode,
+ false,
+ )
+
+ expect(res).toBe('transparent')
+ })
})
diff --git a/src/codegen/utils/__tests__/props-to-str.test.ts b/src/codegen/utils/__tests__/props-to-str.test.ts
index ad95557..264ea93 100644
--- a/src/codegen/utils/__tests__/props-to-str.test.ts
+++ b/src/codegen/utils/__tests__/props-to-str.test.ts
@@ -34,4 +34,42 @@ describe('propsToString', () => {
test('handles empty props', () => {
expect(propsToString({})).toBe('')
})
+
+ test('handles animationName with keyframes function', () => {
+ const res = propsToString({
+ animationName: 'keyframes({"0%":{"opacity":0},"100%":{"opacity":1}})',
+ })
+ expect(res).toContain('animationName={keyframes(')
+ expect(res).toContain('"0%"')
+ expect(res).toContain('"100%"')
+ })
+
+ test('handles animationName with invalid keyframes JSON', () => {
+ const res = propsToString({
+ animationName: 'keyframes(invalid-json)',
+ })
+ expect(res).toBe('animationName={keyframes(invalid-json)}')
+ })
+
+ test('handles animationName starting with keyframes but no parentheses match', () => {
+ const res = propsToString({
+ animationName: 'keyframes(',
+ })
+ expect(res).toBe('animationName={keyframes(}')
+ })
+
+ test('handles animationName without keyframes prefix', () => {
+ const res = propsToString({
+ animationName: 'fadeIn',
+ })
+ expect(res).toBe('animationName="fadeIn"')
+ })
+
+ test('handles object values', () => {
+ const res = propsToString({
+ style: { color: 'red', fontSize: 16 },
+ })
+ expect(res).toContain('style={')
+ expect(res).toContain('"color": "red"')
+ })
})
diff --git a/src/codegen/utils/check-asset-node.ts b/src/codegen/utils/check-asset-node.ts
index 778d568..9f319f8 100644
--- a/src/codegen/utils/check-asset-node.ts
+++ b/src/codegen/utils/check-asset-node.ts
@@ -1,5 +1,30 @@
+function hasSmartAnimateReaction(node: BaseNode | null): boolean {
+ if (!node || node.type === 'DOCUMENT' || node.type === 'PAGE') return false
+ if (
+ 'reactions' in node &&
+ node.reactions?.some((reaction) =>
+ reaction.actions?.some(
+ (action) =>
+ action.type === 'NODE' && action.transition?.type === 'SMART_ANIMATE',
+ ),
+ )
+ )
+ return true
+ return false
+}
+
+function isAnimationTarget(node: SceneNode): boolean {
+ // Check if node itself has SMART_ANIMATE
+ if (hasSmartAnimateReaction(node)) return true
+ // Check if parent has SMART_ANIMATE (node is animation target as child)
+ if (node.parent && hasSmartAnimateReaction(node.parent)) return true
+ return false
+}
+
export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
- if (node.type === 'TEXT') return null
+ if (node.type === 'TEXT' || node.type === 'COMPONENT_SET') return null
+ // if node is an animation target (has keyframes), it should not be treated as an asset
+ if (isAnimationTarget(node)) return null
// vector must be svg
if (['VECTOR', 'STAR', 'POLYGON'].includes(node.type)) return 'svg'
// ellipse with inner radius must be svg
@@ -25,7 +50,9 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
fill.type === 'IMAGE' &&
fill.scaleMode !== 'TILE',
)
- ? 'png'
+ ? node.fills.length === 1
+ ? 'png'
+ : null
: node.fills.every(
(fill: Paint) => fill.visible && fill.type === 'SOLID',
)
@@ -50,7 +77,16 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
return null
return checkAssetNode(children[0])
}
- return children.every((child) => child.visible && checkAssetNode(child))
+ const fillterdChildren = children.filter((child) => child.visible)
+
+ // return children.every((child) => child.visible && checkAssetNode(child))
+ // ? 'svg'
+ // : null
+ return fillterdChildren.every((child) => {
+ const result = checkAssetNode(child)
+ if (result === null) return false
+ return result === 'svg'
+ })
? 'svg'
: null
}
diff --git a/src/codegen/utils/get-page-node.ts b/src/codegen/utils/get-page-node.ts
index 89ae8f2..ab6c5fe 100644
--- a/src/codegen/utils/get-page-node.ts
+++ b/src/codegen/utils/get-page-node.ts
@@ -1,6 +1,7 @@
export function getPageNode(node: BaseNode & ChildrenMixin) {
if (!node.parent) return null
switch (node.parent.type) {
+ case 'COMPONENT_SET':
case 'SECTION':
case 'PAGE':
if (['SECTION', 'PAGE'].includes(node.type)) return null
diff --git a/src/codegen/utils/is-default-prop.ts b/src/codegen/utils/is-default-prop.ts
index 3f1bf50..b7e78b3 100644
--- a/src/codegen/utils/is-default-prop.ts
+++ b/src/codegen/utils/is-default-prop.ts
@@ -24,6 +24,8 @@ const DEFAULT_PROPS_MAP = {
gap: /\b0(px)?\b/,
} as const
export function isDefaultProp(prop: string, value: unknown) {
+ // Don't filter arrays (responsive values)
+ if (Array.isArray(value)) return false
return (
prop in DEFAULT_PROPS_MAP &&
DEFAULT_PROPS_MAP[prop as keyof typeof DEFAULT_PROPS_MAP].test(
diff --git a/src/codegen/utils/is-page-root.ts b/src/codegen/utils/is-page-root.ts
new file mode 100644
index 0000000..2e5b76f
--- /dev/null
+++ b/src/codegen/utils/is-page-root.ts
@@ -0,0 +1,6 @@
+import { getPageNode } from './get-page-node'
+
+export function isPageRoot(node: BaseNode | null | undefined) {
+ if (!node) return false
+ return getPageNode(node as BaseNode & ChildrenMixin) === node
+}
diff --git a/src/codegen/utils/solid-to-string.ts b/src/codegen/utils/solid-to-string.ts
index 59b6de5..912576a 100644
--- a/src/codegen/utils/solid-to-string.ts
+++ b/src/codegen/utils/solid-to-string.ts
@@ -10,5 +10,12 @@ export async function solidToString(solid: SolidPaint) {
if (variable?.name) return `$${toCamel(variable.name)}`
}
if (solid.opacity === 0) return 'transparent'
- return optimizeHex(rgbaToHex(figma.util.rgba(solid.color)))
+ return optimizeHex(
+ rgbaToHex(
+ figma.util.rgba({
+ ...solid.color,
+ a: solid.opacity ?? 1,
+ }),
+ ),
+ )
}
diff --git a/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts b/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts
index b0bc4f9..d785967 100644
--- a/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts
+++ b/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
+import type { Devup } from '../../types'
import { uploadDevupXlsx } from '../upload-devup-xlsx'
describe('uploadDevupXlsx', () => {
@@ -83,6 +84,6 @@ describe('uploadDevupXlsx', () => {
}
const result = await promise
- expect(result).toEqual(testData)
+ expect(result).toEqual(testData as unknown as Devup)
})
})