diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap
index 5830663..c61256e 100644
--- a/src/__tests__/__snapshots__/code.test.ts.snap
+++ b/src/__tests__/__snapshots__/code.test.ts.snap
@@ -6,7 +6,7 @@ exports[`registerCodegen should register codegen 1`] = `
"code":
"export function Test() {
return
- }"
+}"
,
"language": "TYPESCRIPT",
"title": "Test - Components",
@@ -19,7 +19,7 @@ echo 'import { Box } from \\'@devup-ui/react\\'
export function Test() {
return
- }' > src/components/Test.tsx"
+}' > src/components/Test.tsx"
,
"language": "BASH",
"title": "Test - Components CLI (Bash)",
@@ -33,7 +33,7 @@ import { Box } from '@devup-ui/react'
export function Test() {
return
- }
+}
'@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8"
,
"language": "BASH",
diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts
index 295421a..3702655 100644
--- a/src/__tests__/code.test.ts
+++ b/src/__tests__/code.test.ts
@@ -214,3 +214,423 @@ describe('extractImports', () => {
expect(result).toContain('Box')
})
})
+
+describe('extractCustomComponentImports', () => {
+ it('should extract custom component imports', () => {
+ const result = codeModule.extractCustomComponentImports([
+ ['MyComponent', ''],
+ ])
+ expect(result).toContain('CustomButton')
+ expect(result).toContain('CustomInput')
+ expect(result).not.toContain('Box')
+ expect(result).not.toContain('MyComponent')
+ })
+
+ it('should not include devup-ui components', () => {
+ const result = codeModule.extractCustomComponentImports([
+ [
+ 'MyComponent',
+ '',
+ ],
+ ])
+ expect(result).toContain('CustomCard')
+ expect(result).not.toContain('Box')
+ expect(result).not.toContain('Flex')
+ expect(result).not.toContain('VStack')
+ })
+
+ it('should return empty array when no custom components', () => {
+ const result = codeModule.extractCustomComponentImports([
+ ['MyComponent', 'Hello'],
+ ])
+ expect(result).toEqual([])
+ })
+
+ it('should sort custom components alphabetically', () => {
+ const result = codeModule.extractCustomComponentImports([
+ ['MyComponent', ''],
+ ])
+ expect(result).toEqual(['Apple', 'Mango', 'Zebra'])
+ })
+
+ it('should handle multiple components with same custom component', () => {
+ const result = codeModule.extractCustomComponentImports([
+ ['ComponentA', ''],
+ ['ComponentB', ''],
+ ])
+ expect(result).toEqual(['SharedButton'])
+ })
+
+ it('should handle nested custom components', () => {
+ const result = codeModule.extractCustomComponentImports([
+ ['Parent', ''],
+ ])
+ expect(result).toContain('ChildA')
+ expect(result).toContain('ChildB')
+ expect(result).toContain('ChildC')
+ })
+})
+
+describe('registerCodegen with viewport variant', () => {
+ type CodegenHandler = (event: {
+ node: SceneNode
+ language: string
+ }) => Promise
+
+ it('should generate responsive component codes for COMPONENT_SET with viewport variant', async () => {
+ let capturedHandler: CodegenHandler | null = null
+
+ const figmaMock = {
+ editorType: 'dev',
+ mode: 'codegen',
+ command: 'noop',
+ codegen: {
+ on: (_event: string, handler: CodegenHandler) => {
+ capturedHandler = handler
+ },
+ },
+ closePlugin: mock(() => {}),
+ } as unknown as typeof figma
+
+ codeModule.registerCodegen(figmaMock)
+
+ expect(capturedHandler).not.toBeNull()
+ if (capturedHandler === null) throw new Error('Handler not captured')
+
+ const componentSetNode = {
+ type: 'COMPONENT_SET',
+ name: 'ResponsiveButton',
+ visible: true,
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile',
+ visible: true,
+ variantProperties: { viewport: 'mobile' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop',
+ visible: true,
+ variantProperties: { viewport: 'desktop' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ defaultVariant: {
+ type: 'COMPONENT',
+ name: 'viewport=desktop',
+ visible: true,
+ variantProperties: { viewport: 'desktop' },
+ children: [],
+ },
+ } as unknown as SceneNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: componentSetNode,
+ language: 'devup-ui',
+ })
+
+ // Should include responsive components result
+ const responsiveResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title.includes('Responsive'),
+ )
+ expect(responsiveResult).toBeDefined()
+ })
+
+ it('should generate responsive component with multiple variants (viewport + size)', async () => {
+ let capturedHandler: CodegenHandler | null = null
+
+ const figmaMock = {
+ editorType: 'dev',
+ mode: 'codegen',
+ command: 'noop',
+ codegen: {
+ on: (_event: string, handler: CodegenHandler) => {
+ capturedHandler = handler
+ },
+ },
+ closePlugin: mock(() => {}),
+ } as unknown as typeof figma
+
+ codeModule.registerCodegen(figmaMock)
+
+ expect(capturedHandler).not.toBeNull()
+ if (capturedHandler === null) throw new Error('Handler not captured')
+
+ // COMPONENT_SET with both viewport and size variants
+ const componentSetNode = {
+ type: 'COMPONENT_SET',
+ name: 'ResponsiveButton',
+ visible: true,
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'md',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile, size=md',
+ visible: true,
+ variantProperties: { viewport: 'mobile', size: 'md' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, size=md',
+ visible: true,
+ variantProperties: { viewport: 'desktop', size: 'md' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ defaultVariant: {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, size=md',
+ visible: true,
+ variantProperties: { viewport: 'desktop', size: 'md' },
+ children: [],
+ },
+ } as unknown as SceneNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: componentSetNode,
+ language: 'devup-ui',
+ })
+
+ // Should include responsive components result
+ const responsiveResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title.includes('Responsive'),
+ )
+ expect(responsiveResult).toBeDefined()
+
+ // The generated code should include the size variant in the interface
+ const resultWithCode = responsiveResult as { code: string } | undefined
+ if (resultWithCode?.code) {
+ expect(resultWithCode.code).toContain('size')
+ }
+ })
+
+ it('should generate responsive code for node with parent SECTION', async () => {
+ let capturedHandler: CodegenHandler | null = null
+
+ const figmaMock = {
+ editorType: 'dev',
+ mode: 'codegen',
+ command: 'noop',
+ codegen: {
+ on: (_event: string, handler: CodegenHandler) => {
+ capturedHandler = handler
+ },
+ },
+ closePlugin: mock(() => {}),
+ } as unknown as typeof figma
+
+ codeModule.registerCodegen(figmaMock)
+
+ expect(capturedHandler).not.toBeNull()
+ if (capturedHandler === null) throw new Error('Handler not captured')
+
+ // Create a SECTION node with children of different widths
+ const sectionNode = {
+ type: 'SECTION',
+ name: 'ResponsiveSection',
+ visible: true,
+ children: [
+ {
+ type: 'FRAME',
+ name: 'MobileFrame',
+ visible: true,
+ width: 375,
+ height: 200,
+ children: [],
+ layoutMode: 'VERTICAL',
+ },
+ {
+ type: 'FRAME',
+ name: 'DesktopFrame',
+ visible: true,
+ width: 1200,
+ height: 200,
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ },
+ ],
+ }
+
+ // Create a child node that has the SECTION as parent
+ const childNode = {
+ type: 'FRAME',
+ name: 'ChildFrame',
+ visible: true,
+ width: 375,
+ height: 100,
+ children: [],
+ layoutMode: 'VERTICAL',
+ parent: sectionNode,
+ } as unknown as SceneNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: childNode,
+ language: 'devup-ui',
+ })
+
+ // Should include responsive result from parent section
+ const responsiveResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title.includes('Responsive'),
+ )
+ expect(responsiveResult).toBeDefined()
+ })
+
+ it('should generate CLI with custom component imports', async () => {
+ let capturedHandler: CodegenHandler | null = null
+
+ const figmaMock = {
+ editorType: 'dev',
+ mode: 'codegen',
+ command: 'noop',
+ codegen: {
+ on: (_event: string, handler: CodegenHandler) => {
+ capturedHandler = handler
+ },
+ },
+ closePlugin: mock(() => {}),
+ } as unknown as typeof figma
+
+ codeModule.registerCodegen(figmaMock)
+
+ expect(capturedHandler).not.toBeNull()
+ if (capturedHandler === null) throw new Error('Handler not captured')
+
+ // Create a custom component that will be referenced
+ const customComponent = {
+ type: 'COMPONENT',
+ name: 'CustomButton',
+ visible: true,
+ children: [],
+ width: 100,
+ height: 40,
+ layoutMode: 'NONE',
+ componentPropertyDefinitions: {},
+ parent: null,
+ }
+
+ // Create an INSTANCE referencing the custom component
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'CustomButton',
+ visible: true,
+ width: 100,
+ height: 40,
+ getMainComponentAsync: async () => customComponent,
+ }
+
+ // Create a COMPONENT that contains the INSTANCE
+ const componentNode = {
+ type: 'COMPONENT',
+ name: 'MyComponent',
+ visible: true,
+ children: [instanceNode],
+ width: 200,
+ height: 100,
+ layoutMode: 'VERTICAL',
+ componentPropertyDefinitions: {},
+ reactions: [],
+ parent: null,
+ } as unknown as SceneNode
+
+ // Create COMPONENT_SET parent with proper children array
+ const componentSetNode = {
+ type: 'COMPONENT_SET',
+ name: 'MyComponentSet',
+ componentPropertyDefinitions: {},
+ children: [componentNode],
+ defaultVariant: componentNode,
+ reactions: [],
+ }
+
+ // Set parent reference
+ ;(componentNode as { parent: unknown }).parent = componentSetNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: componentNode,
+ language: 'devup-ui',
+ })
+
+ // Should include CLI outputs
+ const bashCLI = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title.includes('CLI (Bash)'),
+ )
+ const powershellCLI = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title.includes('CLI (PowerShell)'),
+ )
+
+ expect(bashCLI).toBeDefined()
+ expect(powershellCLI).toBeDefined()
+
+ // Check that custom component import is included (bash escapes quotes)
+ const bashCode = (bashCLI as { code: string } | undefined)?.code
+ const powershellCode = (powershellCLI as { code: string } | undefined)?.code
+
+ if (bashCode) {
+ expect(bashCode).toContain(
+ "import { CustomButton } from \\'@/components/CustomButton\\'",
+ )
+ }
+ if (powershellCode) {
+ expect(powershellCode).toContain(
+ "import { CustomButton } from '@/components/CustomButton'",
+ )
+ }
+ })
+})
diff --git a/src/code-impl.ts b/src/code-impl.ts
index e98ba11..0b54211 100644
--- a/src/code-impl.ts
+++ b/src/code-impl.ts
@@ -3,6 +3,17 @@ import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
import { exportDevup, importDevup } from './commands/devup'
import { exportAssets } from './commands/exportAssets'
import { exportComponents } from './commands/exportComponents'
+import { getComponentName } from './utils'
+
+const DEVUP_COMPONENTS = [
+ 'Center',
+ 'VStack',
+ 'Flex',
+ 'Grid',
+ 'Box',
+ 'Text',
+ 'Image',
+]
export function extractImports(
componentsCodes: ReadonlyArray,
@@ -10,39 +21,67 @@ export function extractImports(
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) {
+ for (const component of DEVUP_COMPONENTS) {
const regex = new RegExp(`<${component}[\\s/>]`, 'g')
if (regex.test(allCode)) {
imports.add(component)
}
}
- // keyframes 함수 체크
- if (/keyframes\s*\(|keyframes`/.test(allCode)) {
+ if (/\bkeyframes\s*(\(|`)/.test(allCode)) {
imports.add('keyframes')
}
return Array.from(imports).sort()
}
+export function extractCustomComponentImports(
+ componentsCodes: ReadonlyArray,
+): string[] {
+ const allCode = componentsCodes.map(([_, code]) => code).join('\n')
+ const customImports = new Set()
+
+ // Find all component usages in JSX:
+ const componentUsageRegex = /<([A-Z][a-zA-Z0-9]*)/g
+ const matches = allCode.matchAll(componentUsageRegex)
+ for (const match of matches) {
+ const componentName = match[1]
+ // Skip devup-ui components and components defined in this code
+ if (!DEVUP_COMPONENTS.includes(componentName)) {
+ customImports.add(componentName)
+ }
+ }
+
+ return Array.from(customImports).sort()
+}
+
+function generateImportStatements(
+ componentsCodes: ReadonlyArray,
+): string {
+ const devupImports = extractImports(componentsCodes)
+ const customImports = extractCustomComponentImports(componentsCodes)
+
+ const statements: string[] = []
+
+ if (devupImports.length > 0) {
+ statements.push(
+ `import { ${devupImports.join(', ')} } from '@devup-ui/react'`,
+ )
+ }
+
+ for (const componentName of customImports) {
+ statements.push(
+ `import { ${componentName} } from '@/components/${componentName}'`,
+ )
+ }
+
+ return statements.length > 0 ? `${statements.join('\n')}\n\n` : ''
+}
+
function generateBashCLI(
componentsCodes: ReadonlyArray,
): string {
- const imports = extractImports(componentsCodes)
- const importStatement =
- imports.length > 0
- ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n`
- : ''
+ const importStatement = generateImportStatements(componentsCodes)
const commands = [
'mkdir -p src/components',
@@ -60,11 +99,7 @@ function generateBashCLI(
function generatePowerShellCLI(
componentsCodes: ReadonlyArray,
): string {
- const imports = extractImports(componentsCodes)
- const importStatement =
- imports.length > 0
- ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n`
- : ''
+ const importStatement = generateImportStatements(componentsCodes)
const commands = [
'New-Item -ItemType Directory -Force -Path src\\components | Out-Null',
@@ -87,6 +122,20 @@ export function registerCodegen(ctx: typeof figma) {
const codegen = new Codegen(node)
await codegen.run()
const componentsCodes = codegen.getComponentsCodes()
+
+ // Generate responsive component codes with variant support
+ let responsiveComponentsCodes: ReadonlyArray<
+ readonly [string, string]
+ > = []
+ if (node.type === 'COMPONENT_SET') {
+ const componentName = getComponentName(node)
+ responsiveComponentsCodes =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ node,
+ componentName,
+ )
+ }
+
console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`)
const parentSection = ResponsiveCodegen.hasParentSection(node)
@@ -144,6 +193,17 @@ export function registerCodegen(ctx: typeof figma) {
},
] as const)
: []),
+ ...(responsiveComponentsCodes.length > 0
+ ? [
+ {
+ title: `${node.name} - Components Responsive`,
+ language: 'TYPESCRIPT' as const,
+ code: responsiveComponentsCodes
+ .map((code) => code[1])
+ .join('\n\n'),
+ },
+ ]
+ : []),
...responsiveResult,
]
}
diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts
index aa716ae..070ecf3 100644
--- a/src/codegen/Codegen.ts
+++ b/src/codegen/Codegen.ts
@@ -25,11 +25,17 @@ export class Codegen {
private componentTrees: Map = new Map()
constructor(private node: SceneNode) {
- if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') {
- this.node = node.parent
- } else {
- this.node = node
- }
+ this.node = node
+ // if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') {
+ // this.node = node.parent
+ // } else {
+ // this.node = node
+ // }
+ // if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') {
+ // this.node = node.parent
+ // } else {
+ // this.node = node
+ // }
}
getCode() {
@@ -46,120 +52,31 @@ export class Codegen {
)
}
- async addComponent(node: ComponentNode) {
- const childrenCodes =
- 'children' in node
- ? node.children.map(async (child) => {
- if (child.type === 'INSTANCE') {
- const mainComponent = await child.getMainComponentAsync()
- if (mainComponent) await this.addComponent(mainComponent)
- }
+ /**
+ * Run the codegen process: build tree and render to JSX string.
+ */
+ async run(node: SceneNode = this.node, depth: number = 0): Promise {
+ // Build the tree first
+ const tree = await this.buildTree(node)
- return await this.run(child, 0)
- })
- : []
- const props = await getProps(node)
- const selectorProps = await getSelectorProps(node)
- const variants = {}
+ // Render the tree to JSX string
+ const ret = Codegen.renderTree(tree, depth)
- if (selectorProps) {
- Object.assign(props, selectorProps.props)
- Object.assign(variants, selectorProps.variants)
+ if (node === this.node) {
+ this.code = ret
+ this.tree = tree
}
- this.components.set(node, {
- code: renderNode(
- getDevupComponentByProps(props),
- props,
- 2,
- await Promise.all(childrenCodes),
- ),
- variants,
- })
- }
-
- async run(node: SceneNode = this.node, dep: number = 0): Promise {
- 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) {
- // support mask image icon
- props.maskImage = buildCssUrl(props.src as string)
- props.maskRepeat = 'no-repeat'
- props.maskSize = 'contain'
- props.bg = maskColor
- delete props.src
- }
+ // Sync componentTrees to components
+ for (const [compNode, compTree] of this.componentTrees) {
+ if (!this.components.has(compNode)) {
+ this.components.set(compNode, {
+ code: Codegen.renderTree(compTree.tree, 2),
+ variants: compTree.variants,
+ })
}
- const ret = renderNode('src' in props ? 'Image' : 'Box', props, dep, [])
- if (node === this.node) this.code = ret
- return ret
}
- const props = await getProps(node)
- if (
- (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') &&
- ((this.node.type === 'COMPONENT_SET' &&
- node === this.node.defaultVariant) ||
- this.node.type === 'COMPONENT')
- ) {
- await this.addComponent(
- node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
- )
- }
- if (node.type === 'INSTANCE') {
- const mainComponent = await node.getMainComponentAsync()
- if (mainComponent) await this.addComponent(mainComponent)
- let ret = renderNode(getComponentName(mainComponent || node), {}, dep, [])
- if (props.pos) {
- ret = renderNode(
- 'Box',
- {
- pos: props.pos,
- top: props.top,
- 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],
- )
- }
- if (node === this.node) this.code = ret
- return ret
- }
- const childrenCodes = await Promise.all(
- 'children' in node
- ? node.children.map(async (child) => {
- if (child.type === 'INSTANCE') {
- const mainComponent = await child.getMainComponentAsync()
- if (mainComponent) await this.addComponent(mainComponent)
- }
- return await this.run(child)
- })
- : [],
- )
- if (node.type === 'TEXT') {
- const { children, props: _props } = await renderText(node)
- childrenCodes.push(...children)
- Object.assign(props, _props)
- }
- const ret = renderNode(
- getDevupComponentByNode(node, props),
- props,
- dep,
- childrenCodes,
- )
- if (node === this.node) this.code = ret
return ret
}
@@ -344,6 +261,16 @@ export class Codegen {
})
}
+ /**
+ * Check if the node is a COMPONENT_SET with viewport variant.
+ */
+ hasViewportVariant(): boolean {
+ if (this.node.type !== 'COMPONENT_SET') return false
+ return Object.keys(
+ (this.node as ComponentSetNode).componentPropertyDefinitions,
+ ).some((key) => key.toLowerCase() === 'viewport')
+ }
+
/**
* Render a NodeTree to JSX string.
* Static method so it can be used independently.
@@ -354,8 +281,9 @@ export class Codegen {
return renderNode(tree.component, tree.props, depth, tree.textChildren)
}
+ // Children are rendered with depth 0 because renderNode handles indentation internally
const childrenCodes = tree.children.map((child) =>
- Codegen.renderTree(child, depth + 1),
+ Codegen.renderTree(child, 0),
)
return renderNode(tree.component, tree.props, depth, childrenCodes)
}
diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts
new file mode 100644
index 0000000..e4ad9ea
--- /dev/null
+++ b/src/codegen/__tests__/codegen-viewport.test.ts
@@ -0,0 +1,357 @@
+import { afterAll, describe, expect, test } from 'bun:test'
+import { Codegen } from '../Codegen'
+import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
+
+// Mock figma global
+;(globalThis as { figma?: unknown }).figma = {
+ mixed: Symbol('mixed'),
+ util: {
+ rgba: (color: string | RGB | RGBA): RGBA => {
+ if (typeof color === 'string') {
+ const rgbMatch = color.match(/rgb\(([^)]+)\)/)
+ if (rgbMatch) {
+ const values = rgbMatch[1].split(/[,\s/]+/).filter(Boolean)
+ const r = values[0]?.includes('%')
+ ? parseFloat(values[0]) / 100
+ : parseFloat(values[0] || '0') / 255
+ const g = values[1]?.includes('%')
+ ? parseFloat(values[1]) / 100
+ : parseFloat(values[1] || '0') / 255
+ const b = values[2]?.includes('%')
+ ? parseFloat(values[2]) / 100
+ : parseFloat(values[2] || '0') / 255
+ const a = values[3] ? parseFloat(values[3]) : 1
+ return { r, g, b, a }
+ }
+ return { r: 0, g: 0, b: 0, a: 1 }
+ }
+ if (typeof color === 'object') {
+ if ('a' in color) {
+ return color
+ }
+ return { ...color, a: 1 }
+ }
+ return { r: 0, g: 0, b: 0, a: 1 }
+ },
+ },
+ getLocalTextStylesAsync: () => [],
+ getStyleByIdAsync: async () => null,
+ getNodeByIdAsync: async () => null,
+ variables: {
+ getVariableByIdAsync: async () => null,
+ },
+} as unknown as typeof figma
+
+afterAll(() => {
+ ;(globalThis as { figma?: unknown }).figma = undefined
+})
+
+function createComponentNode(
+ name: string,
+ variantProperties: Record,
+ overrides: Partial = {},
+): ComponentNode {
+ return {
+ type: 'COMPONENT',
+ name,
+ variantProperties,
+ children: [],
+ layoutMode: 'VERTICAL',
+ primaryAxisAlignItems: 'MIN',
+ counterAxisAlignItems: 'MIN',
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingLeft: 0,
+ paddingRight: 0,
+ itemSpacing: 0,
+ width: 100,
+ height: 100,
+ visible: true,
+ fills: [],
+ strokes: [],
+ effects: [],
+ cornerRadius: 0,
+ reactions: [],
+ ...overrides,
+ } as unknown as ComponentNode
+}
+
+function createComponentSetNode(
+ name: string,
+ componentPropertyDefinitions: ComponentSetNode['componentPropertyDefinitions'],
+ children: ComponentNode[],
+): ComponentSetNode {
+ return {
+ type: 'COMPONENT_SET',
+ name,
+ componentPropertyDefinitions,
+ children,
+ defaultVariant: children[0],
+ width: 500,
+ height: 500,
+ visible: true,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as ComponentSetNode
+}
+
+describe('Codegen viewport variant', () => {
+ test('hasViewportVariant returns false for non-COMPONENT_SET', () => {
+ const frameNode = {
+ type: 'FRAME',
+ name: 'Frame',
+ children: [],
+ } as unknown as SceneNode
+
+ const codegen = new Codegen(frameNode)
+ expect(codegen.hasViewportVariant()).toBe(false)
+ })
+
+ test('hasViewportVariant returns false when no viewport variant exists', () => {
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'md',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ },
+ [createComponentNode('size=md', { size: 'md' })],
+ )
+
+ const codegen = new Codegen(componentSet)
+ expect(codegen.hasViewportVariant()).toBe(false)
+ })
+
+ test('hasViewportVariant returns true when viewport variant exists (lowercase)', () => {
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'tablet', 'desktop'],
+ },
+ },
+ [createComponentNode('viewport=desktop', { viewport: 'desktop' })],
+ )
+
+ const codegen = new Codegen(componentSet)
+ expect(codegen.hasViewportVariant()).toBe(true)
+ })
+
+ test('hasViewportVariant returns true when viewport variant exists (mixed case)', () => {
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ Viewport: {
+ type: 'VARIANT',
+ defaultValue: 'Desktop',
+ variantOptions: ['Mobile', 'Tablet', 'Desktop'],
+ },
+ },
+ [createComponentNode('Viewport=Desktop', { Viewport: 'Desktop' })],
+ )
+
+ const codegen = new Codegen(componentSet)
+ expect(codegen.hasViewportVariant()).toBe(true)
+ })
+
+ test('generateViewportResponsiveComponents generates responsive code for viewport variants', async () => {
+ const mobileComponent = createComponentNode(
+ 'viewport=mobile',
+ { viewport: 'mobile' },
+ {
+ width: 320,
+ layoutMode: 'VERTICAL',
+ itemSpacing: 8,
+ },
+ )
+
+ const desktopComponent = createComponentNode(
+ 'viewport=desktop',
+ { viewport: 'desktop' },
+ {
+ width: 1200,
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 16,
+ },
+ )
+
+ const componentSet = createComponentSetNode(
+ 'ResponsiveCard',
+ {
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ [mobileComponent, desktopComponent],
+ )
+
+ const codegen = new Codegen(componentSet)
+ expect(codegen.hasViewportVariant()).toBe(true)
+
+ const codes = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'ResponsiveCard',
+ )
+ expect(codes.length).toBe(1)
+ expect(codes[0][0]).toBe('ResponsiveCard')
+ expect(codes[0][1]).toContain('export function ResponsiveCard')
+ })
+
+ test('generateViewportResponsiveComponents excludes viewport from variants interface', async () => {
+ const mobileComponent = createComponentNode('size=md, viewport=mobile', {
+ size: 'md',
+ viewport: 'mobile',
+ })
+
+ const desktopComponent = createComponentNode('size=md, viewport=desktop', {
+ size: 'md',
+ viewport: 'desktop',
+ })
+
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'md',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ [mobileComponent, desktopComponent],
+ )
+
+ const codes = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'Button',
+ )
+
+ expect(codes.length).toBe(1)
+ // Should have size in interface but not viewport
+ expect(codes[0][1]).toContain('size:')
+ expect(codes[0][1]).not.toContain('viewport:')
+ })
+
+ test('generateViewportResponsiveComponents generates responsive arrays for different props', async () => {
+ const mobileComponent = createComponentNode(
+ 'viewport=mobile',
+ { viewport: 'mobile' },
+ {
+ width: 320,
+ height: 200,
+ layoutMode: 'VERTICAL',
+ itemSpacing: 8,
+ paddingTop: 8,
+ paddingBottom: 8,
+ paddingLeft: 8,
+ paddingRight: 8,
+ },
+ )
+
+ const desktopComponent = createComponentNode(
+ 'viewport=desktop',
+ { viewport: 'desktop' },
+ {
+ width: 1200,
+ height: 400,
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 24,
+ paddingTop: 16,
+ paddingBottom: 16,
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
+ )
+
+ const componentSet = createComponentSetNode(
+ 'ResponsiveBox',
+ {
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ [mobileComponent, desktopComponent],
+ )
+
+ const codes = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'ResponsiveBox',
+ )
+
+ expect(codes.length).toBe(1)
+ const generatedCode = codes[0][1]
+
+ // Check that responsive arrays are generated for different prop values
+ // padding should be responsive: p={["8px", null, null, null, "16px"]}
+ expect(generatedCode).toContain('p={')
+ expect(generatedCode).toContain('"8px"')
+ expect(generatedCode).toContain('"16px"')
+ })
+
+ test('groups components with same non-viewport variants together', async () => {
+ // Create components for different size + viewport combinations
+ const smMobile = createComponentNode(
+ 'size=sm, viewport=mobile',
+ { size: 'sm', viewport: 'mobile' },
+ { width: 80 },
+ )
+
+ const smDesktop = createComponentNode(
+ 'size=sm, viewport=desktop',
+ { size: 'sm', viewport: 'desktop' },
+ { width: 100 },
+ )
+
+ const lgMobile = createComponentNode(
+ 'size=lg, viewport=mobile',
+ { size: 'lg', viewport: 'mobile' },
+ { width: 120 },
+ )
+
+ const lgDesktop = createComponentNode(
+ 'size=lg, viewport=desktop',
+ { size: 'lg', viewport: 'desktop' },
+ { width: 160 },
+ )
+
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'sm',
+ variantOptions: ['sm', 'lg'],
+ },
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ [smMobile, smDesktop, lgMobile, lgDesktop],
+ )
+
+ const codes = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'Button',
+ )
+
+ // Should generate 2 groups: one for size=sm and one for size=lg
+ // But since we're using the same component name, it will be 2 entries
+ expect(codes.length).toBe(2)
+ })
+})
diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts
index f24f1aa..189f733 100644
--- a/src/codegen/__tests__/codegen.test.ts
+++ b/src/codegen/__tests__/codegen.test.ts
@@ -569,6 +569,132 @@ describe('Codegen', () => {
} as unknown as VectorNode,
expected: ``,
},
+ {
+ title: 'renders nested svg asset with 3 solid fill boxes in frame',
+ node: {
+ type: 'FRAME',
+ name: 'NestedIcon',
+ children: [
+ {
+ type: 'RECTANGLE',
+ name: 'Box1',
+ children: [],
+ visible: true,
+ isAsset: false,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ {
+ type: 'RECTANGLE',
+ name: 'Box2',
+ children: [],
+ visible: true,
+ isAsset: false,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ {
+ type: 'RECTANGLE',
+ name: 'Box3',
+ children: [],
+ visible: true,
+ isAsset: false,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ isAsset: false,
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ width: 24,
+ height: 24,
+ } as unknown as FrameNode,
+ expected: ``,
+ },
+ {
+ title: 'renders nested svg asset with different colors as image',
+ node: {
+ type: 'FRAME',
+ name: 'NestedMultiColorIcon',
+ children: [
+ {
+ type: 'RECTANGLE',
+ name: 'Box1',
+ children: [],
+ visible: true,
+ isAsset: false,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ {
+ type: 'RECTANGLE',
+ name: 'Box2',
+ children: [],
+ visible: true,
+ isAsset: false,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 1, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ {
+ type: 'RECTANGLE',
+ name: 'Box3',
+ children: [],
+ visible: true,
+ isAsset: false,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0, b: 1 },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ isAsset: true,
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ width: 24,
+ height: 24,
+ } as unknown as FrameNode,
+ expected: ``,
+ },
{
title: 'renders layout for absolute child same size as parent',
node: {
@@ -1054,6 +1180,25 @@ describe('Codegen', () => {
w="110px"
/>`,
},
+ {
+ title: 'renders padding from inferredAutoLayout',
+ node: {
+ type: 'FRAME',
+ name: 'InferredPaddingFrame',
+ children: [],
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ width: 100,
+ height: 50,
+ inferredAutoLayout: {
+ paddingTop: 10,
+ paddingRight: 20,
+ paddingBottom: 10,
+ paddingLeft: 20,
+ },
+ } as unknown as FrameNode,
+ expected: ``,
+ },
{
title: 'renders frame with border radius',
node: {
@@ -3047,9 +3192,9 @@ describe('Codegen', () => {
state: 'default' | 'hover'
}
-export function Button() {
+export function Button({ state }: ButtonProps) {
return
- }`,
+}`,
],
],
},
@@ -3092,7 +3237,7 @@ export function Button() {
'Button',
`export function Button() {
return
- }`,
+}`,
],
],
},
@@ -3158,7 +3303,7 @@ export function Button() {
'Button',
`export function Button() {
return
- }`,
+}`,
],
],
},
@@ -3234,7 +3379,7 @@ export function Button() {
transitionProperty="opacity"
/>
)
- }`,
+}`,
],
],
},
@@ -3290,11 +3435,19 @@ export function Button() {
},
],
} as unknown as ComponentNode,
- expected: `
-
-
-`,
- expectedComponents: [],
+ expected: ``,
+ expectedComponents: [
+ [
+ 'Button',
+ `export interface ButtonProps {
+ state: 'default' | 'hover'
+}
+
+export function Button({ state }: ButtonProps) {
+ return
+}`,
+ ],
+ ],
},
{
title: 'renders component set with press trigger',
@@ -3335,7 +3488,7 @@ export function Button() {
'Button',
`export function Button() {
return
- }`,
+}`,
],
],
},
@@ -3352,7 +3505,7 @@ export function Button() {
'Icon',
`export function Icon() {
return
- }`,
+}`,
],
],
},
@@ -3365,11 +3518,24 @@ export function Button() {
type: 'COMPONENT_SET',
name: 'Button',
children: [],
+ componentPropertyDefinitions: {},
+ defaultVariant: {
+ type: 'COMPONENT',
+ name: 'Default',
+ children: [],
+ },
},
children: [],
} as unknown as ComponentNode,
- expected: ``,
- expectedComponents: [],
+ expected: ``,
+ expectedComponents: [
+ [
+ 'Button',
+ `export function Button() {
+ return
+}`,
+ ],
+ ],
},
])('$title', async ({ node, expected, expectedComponents }) => {
addParent(node)
diff --git a/src/codegen/__tests__/render.test.ts b/src/codegen/__tests__/render.test.ts
index a18450b..d7dd957 100644
--- a/src/codegen/__tests__/render.test.ts
+++ b/src/codegen/__tests__/render.test.ts
@@ -46,7 +46,7 @@ describe('renderComponent', () => {
variants: {} as Record,
expected: `export function Button() {
return
- }`,
+}`,
},
{
title: 'renders component with variants and multiline code',
@@ -59,13 +59,13 @@ describe('renderComponent', () => {
size: "sm" | "lg"
}
-export function Banner() {
+export function Banner({ size }: BannerProps) {
return (
)
- }`,
+}`,
},
])('$title', ({ component, code, variants, expected }) => {
const result = renderComponent(component, code, variants)
diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts
index b782254..1e9141b 100644
--- a/src/codegen/props/auto-layout.ts
+++ b/src/codegen/props/auto-layout.ts
@@ -1,4 +1,5 @@
import { addPx } from '../utils/add-px'
+import { checkAssetNode } from '../utils/check-asset-node'
export function getAutoLayoutProps(
node: SceneNode,
@@ -6,7 +7,8 @@ export function getAutoLayoutProps(
if (
!('inferredAutoLayout' in node) ||
!node.inferredAutoLayout ||
- node.inferredAutoLayout.layoutMode === 'NONE'
+ node.inferredAutoLayout.layoutMode === 'NONE' ||
+ checkAssetNode(node)
)
return
const { layoutMode } = node.inferredAutoLayout
diff --git a/src/codegen/props/background.ts b/src/codegen/props/background.ts
index a8e4b1a..581e929 100644
--- a/src/codegen/props/background.ts
+++ b/src/codegen/props/background.ts
@@ -1,3 +1,4 @@
+import { BLEND_MODE_MAP } from '../utils/blend-mode-map'
import { paintToCSS } from '../utils/paint-to-css'
export async function getBackgroundProps(
@@ -37,27 +38,7 @@ export async function getBackgroundProps(
const combinedBg = cssFills.join(', ')
return {
bg: node.type !== 'TEXT' || gradientText ? combinedBg : null,
- bgBlendMode: {
- NORMAL: null,
- MULTIPLY: 'multiply',
- SCREEN: 'screen',
- OVERLAY: 'overlay',
- DARKEN: 'darken',
- LINEAR_BURN: 'linear-burn',
- COLOR_BURN: 'colorBurn',
- LIGHTEN: 'lighten',
- LINEAR_DODGE: 'linear-dodge',
- COLOR_DODGE: 'color-dodge',
- SOFT_LIGHT: 'soft-light',
- HARD_LIGHT: 'hard-light',
- DIFFERENCE: 'difference',
- EXCLUSION: 'exclusion',
- HUE: 'hue',
- SATURATION: 'saturation',
- COLOR: 'color',
- LUMINOSITY: 'luminosity',
- PASS_THROUGH: null,
- }[backgroundBlend],
+ bgBlendMode: BLEND_MODE_MAP[backgroundBlend],
color: gradientText ? 'transparent' : undefined,
bgClip: gradientText ? 'text' : undefined,
}
diff --git a/src/codegen/props/blend.ts b/src/codegen/props/blend.ts
index 9fc1874..acac968 100644
--- a/src/codegen/props/blend.ts
+++ b/src/codegen/props/blend.ts
@@ -1,3 +1,4 @@
+import { BLEND_MODE_MAP } from '../utils/blend-mode-map'
import { fmtPct } from '../utils/fmtPct'
export function getBlendProps(
@@ -6,28 +7,7 @@ export function getBlendProps(
if ('opacity' in node) {
return {
opacity: node.opacity < 1 ? fmtPct(node.opacity) : undefined,
- mixBlendMode: {
- // same as multiply
- PASS_THROUGH: null,
- NORMAL: null,
- DARKEN: 'darken',
- MULTIPLY: 'multiply',
- LINEAR_BURN: 'linearBurn',
- COLOR_BURN: 'colorBurn',
- LIGHTEN: 'lighten',
- SCREEN: 'screen',
- LINEAR_DODGE: 'linear-dodge',
- COLOR_DODGE: 'color-dodge',
- OVERLAY: 'overlay',
- SOFT_LIGHT: 'soft-light',
- HARD_LIGHT: 'hard-light',
- DIFFERENCE: 'difference',
- EXCLUSION: 'exclusion',
- HUE: 'hue',
- SATURATION: 'saturation',
- COLOR: 'color',
- LUMINOSITY: 'luminosity',
- }[node.blendMode],
+ mixBlendMode: BLEND_MODE_MAP[node.blendMode],
}
}
}
diff --git a/src/codegen/props/layout.ts b/src/codegen/props/layout.ts
index b186633..36300a3 100644
--- a/src/codegen/props/layout.ts
+++ b/src/codegen/props/layout.ts
@@ -53,7 +53,8 @@ function _getLayoutProps(
(node.parent &&
'width' in node.parent &&
node.parent.width > node.width)
- ? checkAssetNode(node)
+ ? checkAssetNode(node) ||
+ ('children' in node && node.children.length === 0)
? addPx(node.width)
: undefined
: '100%',
@@ -61,7 +62,9 @@ function _getLayoutProps(
h:
('children' in node && node.children.length > 0) || node.type === 'TEXT'
? undefined
- : '100%',
+ : 'children' in node && node.children.length === 0
+ ? addPx(node.height)
+ : '100%',
}
}
const hType =
@@ -88,9 +91,7 @@ function _getLayoutProps(
? 1
: undefined,
w:
- rootNode === node &&
- node.width ===
- (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode)?.width
+ rootNode === node
? undefined
: wType === 'FIXED'
? addPx(node.width)
diff --git a/src/codegen/props/padding.ts b/src/codegen/props/padding.ts
index cf769f2..35685b4 100644
--- a/src/codegen/props/padding.ts
+++ b/src/codegen/props/padding.ts
@@ -3,6 +3,19 @@ import { optimizeSpace } from '../utils/optimize-space'
export function getPaddingProps(
node: SceneNode,
): Record | undefined {
+ if (
+ 'inferredAutoLayout' in node &&
+ node.inferredAutoLayout &&
+ 'paddingLeft' in node.inferredAutoLayout
+ ) {
+ return optimizeSpace(
+ 'p',
+ node.inferredAutoLayout.paddingTop,
+ node.inferredAutoLayout.paddingRight,
+ node.inferredAutoLayout.paddingBottom,
+ node.inferredAutoLayout.paddingLeft,
+ )
+ }
if ('paddingLeft' in node) {
return optimizeSpace(
'p',
diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts
index 47c21e5..1985df3 100644
--- a/src/codegen/render/index.ts
+++ b/src/codegen/render/index.ts
@@ -45,12 +45,15 @@ export function renderComponent(
const hasVariants = Object.keys(variants).length > 0
const interfaceCode = hasVariants
? `export interface ${component}Props {
- ${Object.entries(variants)
- .map(([key, value]) => `${key}: ${value}`)
- .join('\n')}
+${Object.entries(variants)
+ .map(([key, value]) => ` ${key}: ${value}`)
+ .join('\n')}
}\n\n`
: ''
- return `${interfaceCode}export function ${component}() {
+ const propsParam = hasVariants
+ ? `{ ${Object.keys(variants).join(', ')} }: ${component}Props`
+ : ''
+ return `${interfaceCode}export function ${component}(${propsParam}) {
return ${
code.includes('\n')
? `(\n${code
@@ -59,7 +62,7 @@ export function renderComponent(
.join('\n')}\n${space(1)})`
: code.trim().replace(/\s+/g, ' ')
}
- }`
+}`
}
function filterProps(props: Record) {
diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts
index 5c4d472..2279621 100644
--- a/src/codegen/responsive/ResponsiveCodegen.ts
+++ b/src/codegen/responsive/ResponsiveCodegen.ts
@@ -1,11 +1,12 @@
import { Codegen } from '../Codegen'
-import { renderNode } from '../render'
+import { renderComponent, renderNode } from '../render'
import type { NodeTree, Props } from '../types'
import {
- BREAKPOINT_INDEX,
type BreakpointKey,
getBreakpointByWidth,
mergePropsToResponsive,
+ mergePropsToVariant,
+ viewportToBreakpoint,
} from './index'
/**
@@ -15,14 +16,17 @@ import {
export class ResponsiveCodegen {
private breakpointNodes: Map = new Map()
- constructor(private sectionNode: SectionNode) {
- this.categorizeChildren()
+ constructor(private sectionNode: SectionNode | null) {
+ if (this.sectionNode) {
+ this.categorizeChildren()
+ }
}
/**
* Group Section children by width to decide breakpoints.
*/
private categorizeChildren() {
+ if (!this.sectionNode) return
for (const child of this.sectionNode.children) {
if ('width' in child) {
const breakpoint = getBreakpointByWidth(child.width)
@@ -78,7 +82,7 @@ export class ResponsiveCodegen {
/**
* Generate merged responsive code from NodeTree objects.
*/
- private generateMergedCode(
+ generateMergedCode(
treesByBreakpoint: Map,
depth: number,
): string {
@@ -185,37 +189,33 @@ export class ResponsiveCodegen {
}
for (const childName of allChildNames) {
- const childByBreakpoint = new Map()
- const presentBreakpoints = new Set()
-
- for (const [bp, childMap] of childrenMaps) {
+ // Find the maximum number of children with this name across all breakpoints
+ let maxChildCount = 0
+ for (const childMap of childrenMaps.values()) {
const children = childMap.get(childName)
- if (children && children.length > 0) {
- childByBreakpoint.set(bp, children[0])
- presentBreakpoints.add(bp)
+ if (children) {
+ maxChildCount = Math.max(maxChildCount, children.length)
}
}
- if (childByBreakpoint.size > 0) {
- // 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],
- )
- const smallestPresentBp = sortedPresentBreakpoints[0]
- const smallestPresentIdx = BREAKPOINT_INDEX[smallestPresentBp]
-
- // 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
+ // Process each child index separately
+ for (let childIndex = 0; childIndex < maxChildCount; childIndex++) {
+ const childByBreakpoint = new Map()
+ const presentBreakpoints = new Set()
+
+ for (const [bp, childMap] of childrenMaps) {
+ const children = childMap.get(childName)
+ if (children && children.length > childIndex) {
+ childByBreakpoint.set(bp, children[childIndex])
+ presentBreakpoints.add(bp)
+ }
+ }
+
+ if (childByBreakpoint.size > 0) {
+ // Add display:none props for breakpoints where child doesn't exist
+ // This handles both:
+ // 1. Child exists only in mobile (needs display:none in pc)
+ // 2. Child exists only in pc (needs display:none in mobile)
for (const bp of treesByBreakpoint.keys()) {
if (!presentBreakpoints.has(bp)) {
const firstChildTree = [...childByBreakpoint.values()][0]
@@ -226,10 +226,10 @@ export class ResponsiveCodegen {
childByBreakpoint.set(bp, hiddenTree)
}
}
- }
- const childCode = this.generateMergedCode(childByBreakpoint, depth)
- childrenCodes.push(childCode)
+ const childCode = this.generateMergedCode(childByBreakpoint, 0)
+ childrenCodes.push(childCode)
+ }
}
}
@@ -252,4 +252,532 @@ export class ResponsiveCodegen {
}
return null
}
+
+ /**
+ * Generate responsive component codes for COMPONENT_SET with viewport variant.
+ * Groups components by non-viewport variants and merges viewport variants.
+ */
+ static async generateViewportResponsiveComponents(
+ componentSet: ComponentSetNode,
+ componentName: string,
+ ): Promise> {
+ // Find viewport variant key
+ const viewportKey = Object.keys(
+ componentSet.componentPropertyDefinitions,
+ ).find((key) => key.toLowerCase() === 'viewport')
+
+ if (!viewportKey) {
+ return []
+ }
+
+ // Get variants excluding viewport
+ const variants: Record = {}
+ for (const [name, definition] of Object.entries(
+ componentSet.componentPropertyDefinitions,
+ )) {
+ if (name.toLowerCase() !== 'viewport' && definition.type === 'VARIANT') {
+ variants[name] =
+ definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') || ''
+ }
+ }
+
+ // Group components by non-viewport variants
+ const groups = new Map>()
+
+ for (const child of componentSet.children) {
+ if (child.type !== 'COMPONENT') continue
+
+ const component = child as ComponentNode
+ const variantProps = component.variantProperties || {}
+
+ const viewportValue = variantProps[viewportKey]
+ if (!viewportValue) continue
+
+ const breakpoint = viewportToBreakpoint(viewportValue)
+ // Create group key from non-viewport variants
+ const otherVariants = Object.entries(variantProps)
+ .filter(([key]) => key.toLowerCase() !== 'viewport')
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => `${key}=${value}`)
+ .join('|')
+
+ const groupKey = otherVariants || '__default__'
+
+ if (!groups.has(groupKey)) {
+ groups.set(groupKey, new Map())
+ }
+ const group = groups.get(groupKey)
+ if (group) {
+ group.set(breakpoint, component)
+ }
+ }
+
+ // Generate responsive code for each group
+ const results: Array = []
+ const responsiveCodegen = new ResponsiveCodegen(null)
+
+ for (const [, viewportComponents] of groups) {
+ // Build trees for each viewport
+ const treesByBreakpoint = new Map()
+ for (const [bp, component] of viewportComponents) {
+ const codegen = new Codegen(component)
+ const tree = await codegen.getTree()
+ treesByBreakpoint.set(bp, tree)
+ }
+
+ // Generate merged responsive code
+ const mergedCode = responsiveCodegen.generateMergedCode(
+ treesByBreakpoint,
+ 2,
+ )
+
+ results.push([
+ componentName,
+ renderComponent(componentName, mergedCode, variants),
+ ] as const)
+ }
+
+ return results
+ }
+
+ /**
+ * Generate component code for COMPONENT_SET with viewport AND other variants.
+ * First merges by viewport (responsive arrays), then by other variants (conditional objects).
+ *
+ * Example output for status variant:
+ * - Props: w={{ scroll: [1, 2], default: [3, 4] }[status]}
+ * - Conditional nodes: {status === "scroll" && }
+ */
+ static async generateVariantResponsiveComponents(
+ componentSet: ComponentSetNode,
+ componentName: string,
+ ): Promise> {
+ // Find viewport variant key
+ const viewportKey = Object.keys(
+ componentSet.componentPropertyDefinitions,
+ ).find((key) => key.toLowerCase() === 'viewport')
+
+ // Get all variant keys excluding viewport
+ const otherVariantKeys: string[] = []
+ const variants: Record = {}
+ for (const [name, definition] of Object.entries(
+ componentSet.componentPropertyDefinitions,
+ )) {
+ if (definition.type === 'VARIANT') {
+ if (name.toLowerCase() !== 'viewport') {
+ otherVariantKeys.push(name)
+ variants[name] =
+ definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') ||
+ ''
+ }
+ }
+ }
+
+ // If no viewport variant, just handle other variants
+ if (!viewportKey) {
+ return ResponsiveCodegen.generateNonViewportVariantComponents(
+ componentSet,
+ componentName,
+ otherVariantKeys,
+ variants,
+ )
+ }
+
+ // If no other variants, use existing viewport-only logic
+ if (otherVariantKeys.length === 0) {
+ return ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ componentName,
+ )
+ }
+
+ // Handle both viewport and other variants
+ // For simplicity, use the first non-viewport variant key
+ const primaryVariantKey = otherVariantKeys[0]
+
+ // Group by variant value first, then by viewport within each group
+ // e.g., { "default" => { "mobile" => Component, "pc" => Component }, "scroll" => { ... } }
+ const byVariantValue = new Map>()
+
+ for (const child of componentSet.children) {
+ if (child.type !== 'COMPONENT') continue
+
+ const component = child as ComponentNode
+ const variantProps = component.variantProperties || {}
+
+ const viewportValue = variantProps[viewportKey]
+ if (!viewportValue) continue
+
+ const breakpoint = viewportToBreakpoint(viewportValue)
+ const variantValue = variantProps[primaryVariantKey] || '__default__'
+
+ if (!byVariantValue.has(variantValue)) {
+ byVariantValue.set(variantValue, new Map())
+ }
+ const byBreakpoint = byVariantValue.get(variantValue)
+ if (byBreakpoint) {
+ byBreakpoint.set(breakpoint, component)
+ }
+ }
+
+ if (byVariantValue.size === 0) {
+ return []
+ }
+
+ const responsiveCodegen = new ResponsiveCodegen(null)
+
+ // Step 1: For each variant value, merge by viewport to get responsive props
+ const mergedTreesByVariant = new Map()
+ const responsivePropsByVariant = new Map<
+ string,
+ Map
+ >()
+
+ for (const [variantValue, viewportComponents] of byVariantValue) {
+ const treesByBreakpoint = new Map()
+ for (const [bp, component] of viewportComponents) {
+ const codegen = new Codegen(component)
+ const tree = await codegen.getTree()
+ treesByBreakpoint.set(bp, tree)
+ }
+ responsivePropsByVariant.set(variantValue, treesByBreakpoint)
+
+ // Get merged tree with responsive props
+ const firstTree = [...treesByBreakpoint.values()][0]
+ const propsMap = new Map()
+ for (const [bp, tree] of treesByBreakpoint) {
+ propsMap.set(bp, tree.props)
+ }
+ const mergedProps = mergePropsToResponsive(propsMap)
+ mergedTreesByVariant.set(variantValue, {
+ ...firstTree,
+ props: mergedProps,
+ })
+ }
+
+ // Step 2: Merge across variant values to create conditional props
+ const mergedCode = responsiveCodegen.generateVariantMergedCode(
+ primaryVariantKey,
+ responsivePropsByVariant,
+ 2,
+ )
+
+ const result: Array = [
+ [componentName, renderComponent(componentName, mergedCode, variants)],
+ ]
+ return result
+ }
+
+ /**
+ * Generate component code for COMPONENT_SET with non-viewport variants only.
+ */
+ private static async generateNonViewportVariantComponents(
+ componentSet: ComponentSetNode,
+ componentName: string,
+ variantKeys: string[],
+ variants: Record,
+ ): Promise> {
+ if (variantKeys.length === 0) {
+ return []
+ }
+
+ // Group components by variant value
+ const primaryVariantKey = variantKeys[0]
+ const componentsByVariant = new Map()
+
+ for (const child of componentSet.children) {
+ if (child.type !== 'COMPONENT') continue
+
+ const component = child as ComponentNode
+ const variantProps = component.variantProperties || {}
+ const variantValue = variantProps[primaryVariantKey] || '__default__'
+
+ if (!componentsByVariant.has(variantValue)) {
+ componentsByVariant.set(variantValue, component)
+ }
+ }
+
+ // Build trees for each variant
+ const treesByVariant = new Map()
+ for (const [variantValue, component] of componentsByVariant) {
+ const codegen = new Codegen(component)
+ const tree = await codegen.getTree()
+ treesByVariant.set(variantValue, tree)
+ }
+
+ // Generate merged code with variant conditionals
+ const responsiveCodegen = new ResponsiveCodegen(null)
+ const mergedCode = responsiveCodegen.generateVariantOnlyMergedCode(
+ primaryVariantKey,
+ treesByVariant,
+ 2,
+ )
+
+ const result: Array = [
+ [componentName, renderComponent(componentName, mergedCode, variants)],
+ ]
+ return result
+ }
+
+ /**
+ * Generate merged code from NodeTree objects across both viewport and variant dimensions.
+ * First applies responsive merging per variant, then variant conditional merging.
+ */
+ generateVariantMergedCode(
+ variantKey: string,
+ treesByVariantAndBreakpoint: Map>,
+ depth: number,
+ ): string {
+ // First, for each variant value, merge across breakpoints
+ const mergedTreesByVariant = new Map()
+
+ for (const [
+ variantValue,
+ treesByBreakpoint,
+ ] of treesByVariantAndBreakpoint) {
+ const firstTree = [...treesByBreakpoint.values()][0]
+ const propsMap = new Map()
+ for (const [bp, tree] of treesByBreakpoint) {
+ propsMap.set(bp, tree.props)
+ }
+ const mergedProps = mergePropsToResponsive(propsMap)
+
+ // Also merge children recursively
+ const mergedChildren =
+ this.mergeChildrenAcrossBreakpoints(treesByBreakpoint)
+
+ mergedTreesByVariant.set(variantValue, {
+ ...firstTree,
+ props: mergedProps,
+ children: mergedChildren,
+ })
+ }
+
+ // Then merge across variant values
+ return this.generateVariantOnlyMergedCode(
+ variantKey,
+ mergedTreesByVariant,
+ depth,
+ )
+ }
+
+ /**
+ * Merge children across breakpoints for a single variant value.
+ */
+ private mergeChildrenAcrossBreakpoints(
+ treesByBreakpoint: Map,
+ ): NodeTree[] {
+ const childrenMaps = new Map>()
+ for (const [bp, tree] of treesByBreakpoint) {
+ childrenMaps.set(bp, this.treeChildrenToMap(tree))
+ }
+
+ const processedChildNames = new Set()
+ const allChildNames: string[] = []
+ const firstBreakpoint = [...treesByBreakpoint.keys()][0]
+ const firstChildrenMap = childrenMaps.get(firstBreakpoint)
+
+ if (firstChildrenMap) {
+ for (const name of firstChildrenMap.keys()) {
+ allChildNames.push(name)
+ processedChildNames.add(name)
+ }
+ }
+
+ for (const childMap of childrenMaps.values()) {
+ for (const name of childMap.keys()) {
+ if (!processedChildNames.has(name)) {
+ allChildNames.push(name)
+ processedChildNames.add(name)
+ }
+ }
+ }
+
+ const mergedChildren: NodeTree[] = []
+
+ for (const childName of allChildNames) {
+ let maxChildCount = 0
+ for (const childMap of childrenMaps.values()) {
+ const children = childMap.get(childName)
+ if (children) {
+ maxChildCount = Math.max(maxChildCount, children.length)
+ }
+ }
+
+ for (let childIndex = 0; childIndex < maxChildCount; childIndex++) {
+ const childByBreakpoint = new Map()
+ const presentBreakpoints = new Set()
+
+ for (const [bp, childMap] of childrenMaps) {
+ const children = childMap.get(childName)
+ if (children && children.length > childIndex) {
+ childByBreakpoint.set(bp, children[childIndex])
+ presentBreakpoints.add(bp)
+ }
+ }
+
+ if (childByBreakpoint.size > 0) {
+ 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)
+ }
+ }
+
+ // Merge this child's props across breakpoints
+ const firstChildTree = [...childByBreakpoint.values()][0]
+ const propsMap = new Map()
+ for (const [bp, tree] of childByBreakpoint) {
+ propsMap.set(bp, tree.props)
+ }
+ const mergedProps = mergePropsToResponsive(propsMap)
+
+ // Recursively merge grandchildren
+ const grandchildren =
+ this.mergeChildrenAcrossBreakpoints(childByBreakpoint)
+
+ mergedChildren.push({
+ ...firstChildTree,
+ props: mergedProps,
+ children: grandchildren,
+ })
+ }
+ }
+ }
+
+ return mergedChildren
+ }
+
+ /**
+ * Generate merged code from NodeTree objects across variant values only (no viewport).
+ * Creates conditional props: { scroll: [...], default: [...] }[status]
+ * And conditional nodes: {status === "scroll" && }
+ */
+ generateVariantOnlyMergedCode(
+ variantKey: string,
+ treesByVariant: Map,
+ depth: number,
+ ): string {
+ const firstTree = [...treesByVariant.values()][0]
+ const allVariants = [...treesByVariant.keys()]
+
+ // Merge props across variants
+ const propsMap = new Map>()
+ for (const [variant, tree] of treesByVariant) {
+ propsMap.set(variant, tree.props)
+ }
+ const mergedProps = mergePropsToVariant(variantKey, propsMap)
+
+ // Handle TEXT nodes
+ if (firstTree.textChildren && firstTree.textChildren.length > 0) {
+ return renderNode(
+ firstTree.component,
+ mergedProps,
+ depth,
+ firstTree.textChildren,
+ )
+ }
+
+ // Merge children across variants
+ const childrenCodes: string[] = []
+ const childrenMaps = new Map>()
+ for (const [variant, tree] of treesByVariant) {
+ childrenMaps.set(variant, this.treeChildrenToMap(tree))
+ }
+
+ const processedChildNames = new Set()
+ const allChildNames: string[] = []
+ const firstVariant = [...treesByVariant.keys()][0]
+ const firstChildrenMap = childrenMaps.get(firstVariant)
+
+ if (firstChildrenMap) {
+ for (const name of firstChildrenMap.keys()) {
+ allChildNames.push(name)
+ processedChildNames.add(name)
+ }
+ }
+
+ for (const childMap of childrenMaps.values()) {
+ for (const name of childMap.keys()) {
+ if (!processedChildNames.has(name)) {
+ allChildNames.push(name)
+ processedChildNames.add(name)
+ }
+ }
+ }
+
+ for (const childName of allChildNames) {
+ let maxChildCount = 0
+ for (const childMap of childrenMaps.values()) {
+ const children = childMap.get(childName)
+ if (children) {
+ maxChildCount = Math.max(maxChildCount, children.length)
+ }
+ }
+
+ for (let childIndex = 0; childIndex < maxChildCount; childIndex++) {
+ const childByVariant = new Map()
+ const presentVariants = new Set()
+
+ for (const [variant, childMap] of childrenMaps) {
+ const children = childMap.get(childName)
+ if (children && children.length > childIndex) {
+ childByVariant.set(variant, children[childIndex])
+ presentVariants.add(variant)
+ }
+ }
+
+ if (childByVariant.size > 0) {
+ // Check if child exists in all variants or only some
+ const existsInAllVariants = allVariants.every((v) =>
+ presentVariants.has(v),
+ )
+
+ if (existsInAllVariants) {
+ // Child exists in all variants - merge props
+ const childCode = this.generateVariantOnlyMergedCode(
+ variantKey,
+ childByVariant,
+ 0,
+ )
+ childrenCodes.push(childCode)
+ } else {
+ // Child exists only in some variants - use conditional rendering
+ const presentVariantsList = [...presentVariants]
+
+ if (presentVariantsList.length === 1) {
+ // Only one variant has this child: {status === "scroll" && }
+ const onlyVariant = presentVariantsList[0]
+ const childTree = childByVariant.get(onlyVariant)
+ if (!childTree) continue
+ const childCode = Codegen.renderTree(childTree, 0)
+ childrenCodes.push(
+ `{${variantKey} === "${onlyVariant}" && ${childCode.includes('\n') ? `(\n${childCode}\n)` : childCode}}`,
+ )
+ } else {
+ // Multiple (but not all) variants have this child
+ // Use conditional rendering with OR
+ const conditions = presentVariantsList
+ .map((v) => `${variantKey} === "${v}"`)
+ .join(' || ')
+ const childCode = this.generateVariantOnlyMergedCode(
+ variantKey,
+ childByVariant,
+ 0,
+ )
+ childrenCodes.push(
+ `{(${conditions}) && ${childCode.includes('\n') ? `(\n${childCode}\n)` : childCode}}`,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ 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 b23d25f..dcc7b0b 100644
--- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
+++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
@@ -11,6 +11,11 @@ const renderNodeMock = mock(
`render:${component}:depth=${depth}:${JSON.stringify(props)}|${children.join(';')}`,
)
+const renderComponentMock = mock(
+ (component: string, code: string, variants: Record) =>
+ `component:${component}:${JSON.stringify(variants)}|${code}`,
+)
+
// Mock Codegen class
const mockGetTree = mock(
async (): Promise => ({
@@ -35,7 +40,10 @@ describe('ResponsiveCodegen', () => {
let ResponsiveCodegen: typeof import('../ResponsiveCodegen').ResponsiveCodegen
beforeEach(async () => {
- mock.module('../../render', () => ({ renderNode: renderNodeMock }))
+ mock.module('../../render', () => ({
+ renderNode: renderNodeMock,
+ renderComponent: renderComponentMock,
+ }))
mock.module('../../Codegen', () => ({ Codegen: MockCodegen }))
;({ ResponsiveCodegen } = await import('../ResponsiveCodegen'))
@@ -160,7 +168,547 @@ describe('ResponsiveCodegen', () => {
it('static helpers detect section and parent section', () => {
const section = { type: 'SECTION' } as unknown as SectionNode
const frame = { type: 'FRAME', parent: section } as unknown as SceneNode
+ const nonSection = { type: 'FRAME' } as unknown as SceneNode
+ const nodeWithoutSectionParent = {
+ type: 'FRAME',
+ parent: { type: 'FRAME' },
+ } as unknown as SceneNode
+
expect(ResponsiveCodegen.canGenerateResponsive(section)).toBeTrue()
+ expect(ResponsiveCodegen.canGenerateResponsive(nonSection)).toBeFalse()
expect(ResponsiveCodegen.hasParentSection(frame)).toEqual(section)
+ expect(
+ ResponsiveCodegen.hasParentSection(nodeWithoutSectionParent),
+ ).toBeNull()
+ })
+
+ it('generateViewportResponsiveComponents returns empty when no viewport variant', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'NoViewport',
+ componentPropertyDefinitions: {
+ size: {
+ type: 'VARIANT',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ },
+ children: [],
+ } as unknown as ComponentSetNode
+
+ const result = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'NoViewport',
+ )
+ expect(result).toEqual([])
+ })
+
+ it('generateViewportResponsiveComponents processes non-viewport variants', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'MultiVariant',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ size: {
+ type: 'VARIANT',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile, size=md',
+ variantProperties: { viewport: 'mobile', size: 'md' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, size=md',
+ variantProperties: { viewport: 'desktop', size: 'md' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'MultiVariant',
+ )
+
+ expect(result.length).toBeGreaterThan(0)
+ // Check that the result includes the component name
+ expect(result[0][0]).toBe('MultiVariant')
+ // Check that the generated code includes the size variant type
+ expect(result[0][1]).toContain('size')
+ })
+
+ it('handles component without viewport in variantProperties', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'PartialViewport',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile',
+ variantProperties: { viewport: 'mobile' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'no-viewport',
+ variantProperties: {}, // No viewport property
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ {
+ type: 'FRAME', // Not a COMPONENT type
+ name: 'frame-child',
+ children: [],
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'PartialViewport',
+ )
+
+ // Should still generate responsive code for the valid component
+ expect(result.length).toBeGreaterThanOrEqual(0)
+ })
+
+ it('handles null sectionNode in constructor', () => {
+ const generator = new ResponsiveCodegen(null)
+ expect(generator).toBeDefined()
+ })
+
+ it('sorts multiple non-viewport variants alphabetically', async () => {
+ // Multiple non-viewport variants to trigger the sort callback
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'MultiPropVariant',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ size: {
+ type: 'VARIANT',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ color: {
+ type: 'VARIANT',
+ variantOptions: ['red', 'blue', 'green'],
+ },
+ state: {
+ type: 'VARIANT',
+ variantOptions: ['default', 'hover', 'active'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile, size=md, color=red, state=default',
+ variantProperties: {
+ viewport: 'mobile',
+ size: 'md',
+ color: 'red',
+ state: 'default',
+ },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, size=md, color=red, state=default',
+ variantProperties: {
+ viewport: 'desktop',
+ size: 'md',
+ color: 'red',
+ state: 'default',
+ },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result = await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'MultiPropVariant',
+ )
+
+ expect(result.length).toBeGreaterThan(0)
+ // Check that all non-viewport variants are in the interface
+ expect(result[0][1]).toContain('size')
+ expect(result[0][1]).toContain('color')
+ expect(result[0][1]).toContain('state')
+ })
+
+ describe('generateVariantOnlyMergedCode', () => {
+ it('merges props across variants with conditional syntax', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const treesByVariant = new Map([
+ [
+ 'scroll',
+ {
+ component: 'Flex',
+ props: { w: '100px', h: '200px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'default',
+ {
+ component: 'Flex',
+ props: { w: '300px', h: '200px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'status',
+ treesByVariant,
+ 0,
+ )
+
+ // h should be same value (200px)
+ expect(result).toContain('"h":"200px"')
+ // w should be variant conditional
+ expect(result).toContain('scroll')
+ expect(result).toContain('default')
+ })
+
+ it('renders conditional nodes for variant-only children', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const scrollOnlyChild: NodeTree = {
+ component: 'Box',
+ props: { id: 'ScrollOnly' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'ScrollOnlyChild',
+ }
+
+ const treesByVariant = new Map([
+ [
+ 'scroll',
+ {
+ component: 'Flex',
+ props: {},
+ children: [scrollOnlyChild],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'default',
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'status',
+ treesByVariant,
+ 0,
+ )
+
+ // Should contain conditional rendering syntax
+ expect(result).toContain('status === "scroll"')
+ expect(result).toContain('&&')
+ })
+
+ it('merges children that exist in all variants', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const sharedChild: NodeTree = {
+ component: 'Text',
+ props: { fontSize: '16px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'SharedText',
+ }
+
+ const treesByVariant = new Map([
+ [
+ 'scroll',
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...sharedChild, props: { fontSize: '14px' } }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'default',
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...sharedChild, props: { fontSize: '16px' } }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'status',
+ treesByVariant,
+ 0,
+ )
+
+ // Should contain merged child without conditional
+ expect(result).not.toContain('status === "scroll" &&')
+ // Should contain variant conditional for fontSize
+ expect(result).toContain('scroll')
+ expect(result).toContain('default')
+ })
+
+ it('renders OR conditional for child existing in multiple but not all variants', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const partialChild: NodeTree = {
+ component: 'Box',
+ props: { id: 'PartialChild' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'PartialChild',
+ }
+
+ const treesByVariant = new Map([
+ [
+ 'scroll',
+ {
+ component: 'Flex',
+ props: {},
+ children: [partialChild],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'hover',
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...partialChild, props: { id: 'PartialChildHover' } }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'default',
+ {
+ component: 'Flex',
+ props: {},
+ children: [], // No child in default variant
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'status',
+ treesByVariant,
+ 0,
+ )
+
+ // Should contain OR conditional for multiple variants
+ expect(result).toContain('status === "scroll"')
+ expect(result).toContain('status === "hover"')
+ expect(result).toContain('||')
+ expect(result).toContain('&&')
+ expect(result).toMatchSnapshot()
+ })
+ })
+
+ describe('generateVariantResponsiveComponents', () => {
+ it('handles component set with only non-viewport variants', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'StatusVariant',
+ componentPropertyDefinitions: {
+ status: {
+ type: 'VARIANT',
+ variantOptions: ['scroll', 'default'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'status=scroll',
+ variantProperties: { status: 'scroll' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 100,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'status=default',
+ variantProperties: { status: 'default' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'StatusVariant',
+ )
+
+ expect(result.length).toBe(1)
+ expect(result[0][0]).toBe('StatusVariant')
+ expect(result[0][1]).toContain('status')
+ })
+
+ it('delegates to generateViewportResponsiveComponents when only viewport exists', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'ViewportOnly',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile',
+ variantProperties: { viewport: 'mobile' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop',
+ variantProperties: { viewport: 'desktop' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'ViewportOnly',
+ )
+
+ expect(result.length).toBe(1)
+ expect(result[0][0]).toBe('ViewportOnly')
+ })
+
+ it('handles both viewport and other variants', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'Combined',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ status: {
+ type: 'VARIANT',
+ variantOptions: ['scroll', 'default'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile, status=scroll',
+ variantProperties: { viewport: 'mobile', status: 'scroll' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, status=scroll',
+ variantProperties: { viewport: 'desktop', status: 'scroll' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile, status=default',
+ variantProperties: { viewport: 'mobile', status: 'default' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 200,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, status=default',
+ variantProperties: { viewport: 'desktop', status: 'default' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 1200,
+ height: 200,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Combined',
+ )
+
+ expect(result.length).toBe(1)
+ expect(result[0][0]).toBe('Combined')
+ // Should have status in interface
+ expect(result[0][1]).toContain('status')
+ })
})
})
diff --git a/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap b/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap
new file mode 100644
index 0000000..f0b7828
--- /dev/null
+++ b/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap
@@ -0,0 +1,3 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`ResponsiveCodegen generateVariantOnlyMergedCode renders OR conditional for child existing in multiple but not all variants 1`] = `"render:Flex:depth=0:{}|{(status === "scroll" || status === "hover") && render:Box:depth=0:{"id":{"__variantProp":true,"variantKey":"status","values":{"scroll":"PartialChild","hover":"PartialChildHover"}}}|}"`;
diff --git a/src/codegen/responsive/__tests__/index.test.ts b/src/codegen/responsive/__tests__/index.test.ts
index c1e0309..c403fed 100644
--- a/src/codegen/responsive/__tests__/index.test.ts
+++ b/src/codegen/responsive/__tests__/index.test.ts
@@ -4,6 +4,7 @@ import {
groupChildrenByBreakpoint,
groupNodesByName,
optimizeResponsiveValue,
+ viewportToBreakpoint,
} from '../index'
describe('responsive index helpers', () => {
@@ -66,4 +67,25 @@ describe('responsive index helpers', () => {
])
expect(optimized).toEqual(obj)
})
+
+ it('converts viewport variant values to breakpoints (case-insensitive)', () => {
+ // lowercase
+ expect(viewportToBreakpoint('mobile')).toBe('mobile')
+ expect(viewportToBreakpoint('tablet')).toBe('tablet')
+ expect(viewportToBreakpoint('desktop')).toBe('pc')
+
+ // uppercase
+ expect(viewportToBreakpoint('MOBILE')).toBe('mobile')
+ expect(viewportToBreakpoint('TABLET')).toBe('tablet')
+ expect(viewportToBreakpoint('DESKTOP')).toBe('pc')
+
+ // mixed case
+ expect(viewportToBreakpoint('Mobile')).toBe('mobile')
+ expect(viewportToBreakpoint('Tablet')).toBe('tablet')
+ expect(viewportToBreakpoint('Desktop')).toBe('pc')
+
+ // unknown values default to pc
+ expect(viewportToBreakpoint('unknown')).toBe('pc')
+ expect(viewportToBreakpoint('')).toBe('pc')
+ })
})
diff --git a/src/codegen/responsive/__tests__/mergePropsToVariant.test.ts b/src/codegen/responsive/__tests__/mergePropsToVariant.test.ts
new file mode 100644
index 0000000..4316f4b
--- /dev/null
+++ b/src/codegen/responsive/__tests__/mergePropsToVariant.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from 'bun:test'
+import {
+ createVariantPropValue,
+ isVariantPropValue,
+ mergePropsToVariant,
+} from '../index'
+
+describe('mergePropsToVariant', () => {
+ it('returns props as-is for single variant', () => {
+ const input = new Map([['scroll', { w: '100px', h: '200px' }]])
+ const result = mergePropsToVariant('status', input)
+ expect(result).toEqual({ w: '100px', h: '200px' })
+ })
+
+ it('returns single value when all variants have same value', () => {
+ const input = new Map([
+ ['scroll', { w: '100px' }],
+ ['default', { w: '100px' }],
+ ])
+ const result = mergePropsToVariant('status', input)
+ expect(result).toEqual({ w: '100px' })
+ })
+
+ it('creates VariantPropValue when variants have different values', () => {
+ const input = new Map([
+ ['scroll', { w: '100px' }],
+ ['default', { w: '200px' }],
+ ])
+ const result = mergePropsToVariant('status', input)
+
+ expect(isVariantPropValue(result.w)).toBe(true)
+ const variantProp = result.w as ReturnType
+ expect(variantProp.variantKey).toBe('status')
+ expect(variantProp.values).toEqual({ scroll: '100px', default: '200px' })
+ })
+
+ it('handles responsive arrays within variants', () => {
+ const input = new Map([
+ ['scroll', { w: ['10px', null, '20px'] }],
+ ['default', { w: ['30px', null, '40px'] }],
+ ])
+ const result = mergePropsToVariant('status', input)
+
+ expect(isVariantPropValue(result.w)).toBe(true)
+ const variantProp = result.w as ReturnType
+ expect(variantProp.values).toEqual({
+ scroll: ['10px', null, '20px'],
+ default: ['30px', null, '40px'],
+ })
+ })
+
+ it('filters out null values from variant object', () => {
+ const input = new Map([
+ ['scroll', { w: '100px' }],
+ ['default', { w: null }],
+ ])
+ const result = mergePropsToVariant('status', input)
+
+ expect(isVariantPropValue(result.w)).toBe(true)
+ const variantProp = result.w as ReturnType
+ expect(variantProp.values).toEqual({ scroll: '100px' })
+ })
+
+ it('omits props when all values are null', () => {
+ const input = new Map([
+ ['scroll', { w: null }],
+ ['default', { w: null }],
+ ])
+ const result = mergePropsToVariant('status', input)
+ expect(result).toEqual({})
+ })
+
+ it('handles mixed props - some same, some different', () => {
+ const input = new Map([
+ ['scroll', { w: '100px', h: '200px', bg: 'red' }],
+ ['default', { w: '100px', h: '300px', bg: 'blue' }],
+ ])
+ const result = mergePropsToVariant('status', input)
+
+ // w should be single value (same in both)
+ expect(result.w).toBe('100px')
+
+ // h should be VariantPropValue (different)
+ expect(isVariantPropValue(result.h)).toBe(true)
+ const hProp = result.h as ReturnType
+ expect(hProp.values).toEqual({ scroll: '200px', default: '300px' })
+
+ // bg should be VariantPropValue (different)
+ expect(isVariantPropValue(result.bg)).toBe(true)
+ const bgProp = result.bg as ReturnType
+ expect(bgProp.values).toEqual({ scroll: 'red', default: 'blue' })
+ })
+
+ it('handles props that exist only in some variants', () => {
+ const input = new Map([
+ ['scroll', { w: '100px', display: 'none' }],
+ ['default', { w: '200px' }],
+ ])
+ const result = mergePropsToVariant('status', input)
+
+ expect(isVariantPropValue(result.w)).toBe(true)
+ expect(isVariantPropValue(result.display)).toBe(true)
+
+ const displayProp = result.display as ReturnType<
+ typeof createVariantPropValue
+ >
+ expect(displayProp.values).toEqual({ scroll: 'none' })
+ })
+
+ it('handles three or more variants', () => {
+ const input = new Map([
+ ['scroll', { w: '100px' }],
+ ['default', { w: '200px' }],
+ ['expanded', { w: '300px' }],
+ ])
+ const result = mergePropsToVariant('status', input)
+
+ expect(isVariantPropValue(result.w)).toBe(true)
+ const variantProp = result.w as ReturnType
+ expect(variantProp.values).toEqual({
+ scroll: '100px',
+ default: '200px',
+ expanded: '300px',
+ })
+ })
+})
+
+describe('isVariantPropValue', () => {
+ it('returns true for VariantPropValue', () => {
+ const value = createVariantPropValue('status', { scroll: '10px' })
+ expect(isVariantPropValue(value)).toBe(true)
+ })
+
+ it('returns false for plain object', () => {
+ expect(isVariantPropValue({ a: 1 })).toBe(false)
+ })
+
+ it('returns false for array', () => {
+ expect(isVariantPropValue([1, 2, 3])).toBe(false)
+ })
+
+ it('returns false for primitives', () => {
+ expect(isVariantPropValue('string')).toBe(false)
+ expect(isVariantPropValue(123)).toBe(false)
+ expect(isVariantPropValue(null)).toBe(false)
+ expect(isVariantPropValue(undefined)).toBe(false)
+ })
+})
+
+describe('createVariantPropValue', () => {
+ it('creates correct structure', () => {
+ const value = createVariantPropValue('status', {
+ scroll: '10px',
+ default: '20px',
+ })
+
+ expect(value.__variantProp).toBe(true)
+ expect(value.variantKey).toBe('status')
+ expect(value.values).toEqual({ scroll: '10px', default: '20px' })
+ })
+})
diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts
index e1df774..20c1224 100644
--- a/src/codegen/responsive/index.ts
+++ b/src/codegen/responsive/index.ts
@@ -73,6 +73,51 @@ const SPECIAL_PROPS_WITH_INITIAL = new Set([
'w',
'h',
'textAlign',
+ // layout related
+ 'flexDir',
+ 'flexWrap',
+ 'justify',
+ 'alignItems',
+ 'alignContent',
+ 'alignSelf',
+ 'gap',
+ 'rowGap',
+ 'columnGap',
+ 'flex',
+ 'flexGrow',
+ 'flexShrink',
+ 'flexBasis',
+ 'order',
+ // grid layout
+ 'gridTemplateColumns',
+ 'gridTemplateRows',
+ 'gridColumn',
+ 'gridRow',
+ 'gridArea',
+ // position related
+ 'top',
+ 'right',
+ 'bottom',
+ 'left',
+ 'zIndex',
+ // overflow
+ 'overflow',
+ 'overflowX',
+ 'overflowY',
+ 'p',
+ 'pt',
+ 'pr',
+ 'pb',
+ 'pl',
+ 'px',
+ 'py',
+ 'm',
+ 'mt',
+ 'mr',
+ 'mb',
+ 'ml',
+ 'mx',
+ 'my',
])
/**
@@ -258,3 +303,126 @@ export function groupNodesByName(
return result
}
+
+/**
+ * Convert viewport variant value to BreakpointKey.
+ * Viewport values: "desktop" | "tablet" | "mobile" (case-insensitive comparison)
+ */
+export function viewportToBreakpoint(viewport: string): BreakpointKey {
+ const lower = viewport.toLowerCase()
+ if (lower === 'mobile') return 'mobile'
+ if (lower === 'tablet') return 'tablet'
+ return 'pc' // desktop → pc
+}
+
+/**
+ * Represents a prop value that varies by variant.
+ * The value is an object mapping variant values to prop values,
+ * followed by bracket access with the variant prop name.
+ *
+ * Example: { scroll: [1, 2], default: [3, 4] }[status]
+ */
+export interface VariantPropValue {
+ __variantProp: true
+ variantKey: string // e.g., 'status'
+ values: Record // e.g., { scroll: [1, 2], default: [3, 4] }
+}
+
+/**
+ * Check if a value is a VariantPropValue.
+ */
+export function isVariantPropValue(value: unknown): value is VariantPropValue {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ '__variantProp' in value &&
+ (value as VariantPropValue).__variantProp === true
+ )
+}
+
+/**
+ * Create a VariantPropValue.
+ */
+export function createVariantPropValue(
+ variantKey: string,
+ values: Record,
+): VariantPropValue {
+ return {
+ __variantProp: true,
+ variantKey,
+ values,
+ }
+}
+
+/**
+ * Merge props across variants into variant-conditional objects.
+ *
+ * If all variants have the same value for a prop, it returns the single value.
+ * If values differ, it creates a VariantPropValue.
+ *
+ * Each variant's props may already contain responsive arrays from breakpoint merging.
+ *
+ * Example:
+ * Input:
+ * variantKey: 'status'
+ * variantProps: Map { 'scroll' => { w: [1, 2] }, 'default' => { w: [3, 4] } }
+ * Output:
+ * { w: { __variantProp: true, variantKey: 'status', values: { scroll: [1, 2], default: [3, 4] } } }
+ */
+export function mergePropsToVariant(
+ variantKey: string,
+ variantProps: Map>,
+): Record {
+ const result: Record = {}
+
+ // If only one variant, return props as-is.
+ if (variantProps.size === 1) {
+ const onlyProps = [...variantProps.values()][0]
+ return onlyProps ? { ...onlyProps } : {}
+ }
+
+ // Collect all prop keys.
+ const allKeys = new Set()
+ for (const props of variantProps.values()) {
+ for (const key of Object.keys(props)) {
+ allKeys.add(key)
+ }
+ }
+
+ for (const key of allKeys) {
+ // Collect values for each variant.
+ const valuesByVariant: Record = {}
+ let hasValue = false
+
+ for (const [variant, props] of variantProps) {
+ const value = key in props ? (props[key] as PropValue) : null
+ if (value !== null && value !== undefined) {
+ hasValue = true
+ }
+ valuesByVariant[variant] = value ?? null
+ }
+
+ if (!hasValue) continue
+
+ // Check if all variants have the same value.
+ const values = Object.values(valuesByVariant)
+ const allSame = values.every((v) => isEqual(v, values[0]))
+
+ if (allSame && values[0] !== null) {
+ result[key] = values[0]
+ } else {
+ // Filter out null values from the variant object
+ const filteredValues: Record = {}
+ for (const [variant, value] of Object.entries(valuesByVariant)) {
+ if (value !== null) {
+ filteredValues[variant] = value
+ }
+ }
+ if (Object.keys(filteredValues).length > 0) {
+ result[key] = createVariantPropValue(variantKey, filteredValues)
+ }
+ }
+ }
+
+ return result
+}
diff --git a/src/codegen/utils/__tests__/props-to-str.test.ts b/src/codegen/utils/__tests__/props-to-str.test.ts
index 264ea93..4950632 100644
--- a/src/codegen/utils/__tests__/props-to-str.test.ts
+++ b/src/codegen/utils/__tests__/props-to-str.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
+import { createVariantPropValue } from '../../responsive'
import { propsToString } from '../props-to-str'
describe('propsToString', () => {
@@ -72,4 +73,87 @@ describe('propsToString', () => {
expect(res).toContain('style={')
expect(res).toContain('"color": "red"')
})
+
+ test('handles VariantPropValue with simple values', () => {
+ const variantProp = createVariantPropValue('status', {
+ scroll: '10px',
+ default: '20px',
+ })
+ const res = propsToString({ w: variantProp })
+ expect(res).toBe('w={{ scroll: "10px", default: "20px" }[status]}')
+ })
+
+ test('handles VariantPropValue with array values (responsive)', () => {
+ const variantProp = createVariantPropValue('status', {
+ scroll: ['10px', null, '20px'],
+ default: ['30px', null, '40px'],
+ })
+ const res = propsToString({ w: variantProp })
+ expect(res).toBe(
+ 'w={{ scroll: ["10px", null, "20px"], default: ["30px", null, "40px"] }[status]}',
+ )
+ })
+
+ test('handles VariantPropValue with numeric values', () => {
+ const variantProp = createVariantPropValue('size', {
+ sm: 10,
+ lg: 20,
+ })
+ const res = propsToString({ gap: variantProp })
+ expect(res).toBe('gap={{ sm: 10, lg: 20 }[size]}')
+ })
+
+ test('VariantPropValue does not trigger newline separator', () => {
+ const variantProp = createVariantPropValue('status', {
+ scroll: '10px',
+ default: '20px',
+ })
+ const res = propsToString({
+ w: variantProp,
+ h: '100px',
+ bg: 'red',
+ })
+ // Should use space separator, not newline
+ expect(res).not.toContain('\n')
+ })
+
+ test('handles VariantPropValue with object values', () => {
+ const variantProp = createVariantPropValue('status', {
+ scroll: { x: 1, y: 2 },
+ default: { x: 3, y: 4 },
+ })
+ const res = propsToString({ transform: variantProp })
+ expect(res).toContain('scroll: {"x":1,"y":2}')
+ expect(res).toContain('default: {"x":3,"y":4}')
+ })
+
+ test('handles VariantPropValue with boolean values', () => {
+ const variantProp = createVariantPropValue('status', {
+ scroll: true,
+ default: false,
+ })
+ const res = propsToString({ visible: variantProp })
+ expect(res).toBe('visible={{ scroll: true, default: false }[status]}')
+ })
+
+ test('handles VariantPropValue with undefined values in array', () => {
+ const variantProp = createVariantPropValue('status', {
+ scroll: [undefined, '10px'],
+ default: ['20px', undefined],
+ })
+ const res = propsToString({ w: variantProp })
+ expect(res).toContain('scroll: [undefined, "10px"]')
+ expect(res).toContain('default: ["20px", undefined]')
+ })
+
+ test('handles VariantPropValue with symbol values (fallback case)', () => {
+ const sym = Symbol('test')
+ const variantProp = createVariantPropValue('status', {
+ scroll: sym as unknown as string,
+ default: '20px',
+ })
+ const res = propsToString({ w: variantProp })
+ expect(res).toContain('scroll: Symbol(test)')
+ expect(res).toContain('default: "20px"')
+ })
})
diff --git a/src/codegen/utils/add-px.ts b/src/codegen/utils/add-px.ts
index 9e9c0ab..43a7364 100644
--- a/src/codegen/utils/add-px.ts
+++ b/src/codegen/utils/add-px.ts
@@ -1,3 +1,5 @@
+import { formatNumber } from './format-number'
+
export function addPx(value: unknown, fallback: string): string
export function addPx(value: unknown, fallback?: string): string | undefined
export function addPx(
@@ -6,12 +8,7 @@ export function addPx(
) {
if (typeof value !== 'number') return fallback
- // Round to 2 decimal places (same as fmtPct)
- const rounded = Math.round(value * 100) / 100
- const fixed = rounded.toFixed(2)
-
- // Remove unnecessary trailing zeros
- const str = fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')
+ const str = formatNumber(value)
if (str === '0') return fallback
return `${str}px`
diff --git a/src/codegen/utils/blend-mode-map.ts b/src/codegen/utils/blend-mode-map.ts
new file mode 100644
index 0000000..9232a14
--- /dev/null
+++ b/src/codegen/utils/blend-mode-map.ts
@@ -0,0 +1,24 @@
+/**
+ * Figma BlendMode to CSS blend-mode mapping
+ */
+export const BLEND_MODE_MAP: Record = {
+ PASS_THROUGH: null,
+ NORMAL: null,
+ DARKEN: 'darken',
+ MULTIPLY: 'multiply',
+ LINEAR_BURN: 'linearBurn',
+ COLOR_BURN: 'colorBurn',
+ LIGHTEN: 'lighten',
+ SCREEN: 'screen',
+ LINEAR_DODGE: 'linear-dodge',
+ COLOR_DODGE: 'color-dodge',
+ OVERLAY: 'overlay',
+ SOFT_LIGHT: 'soft-light',
+ HARD_LIGHT: 'hard-light',
+ DIFFERENCE: 'difference',
+ EXCLUSION: 'exclusion',
+ HUE: 'hue',
+ SATURATION: 'saturation',
+ COLOR: 'color',
+ LUMINOSITY: 'luminosity',
+}
diff --git a/src/codegen/utils/check-asset-node.ts b/src/codegen/utils/check-asset-node.ts
index 9f319f8..fa2f90b 100644
--- a/src/codegen/utils/check-asset-node.ts
+++ b/src/codegen/utils/check-asset-node.ts
@@ -21,7 +21,10 @@ function isAnimationTarget(node: SceneNode): boolean {
return false
}
-export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
+export function checkAssetNode(
+ node: SceneNode,
+ nested = false,
+): 'svg' | 'png' | 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
@@ -56,10 +59,17 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
: node.fills.every(
(fill: Paint) => fill.visible && fill.type === 'SOLID',
)
- ? null
+ ? nested
+ ? 'svg'
+ : null
: 'svg'
: null
- : null
+ : nested &&
+ 'fills' in node &&
+ Array.isArray(node.fills) &&
+ node.fills.every((fill) => fill.visible && fill.type === 'SOLID')
+ ? 'svg'
+ : null
}
const { children } = node
if (children.length === 1) {
@@ -75,7 +85,7 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
: true))
)
return null
- return checkAssetNode(children[0])
+ return checkAssetNode(children[0], true)
}
const fillterdChildren = children.filter((child) => child.visible)
@@ -83,7 +93,7 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null {
// ? 'svg'
// : null
return fillterdChildren.every((child) => {
- const result = checkAssetNode(child)
+ const result = checkAssetNode(child, true)
if (result === null) return false
return result === 'svg'
})
diff --git a/src/codegen/utils/fmtPct.ts b/src/codegen/utils/fmtPct.ts
index e296398..cf6aadf 100644
--- a/src/codegen/utils/fmtPct.ts
+++ b/src/codegen/utils/fmtPct.ts
@@ -1,12 +1,5 @@
-export function fmtPct(n: number) {
- // Round to 2 decimal places
- const rounded = Math.round(n * 100) / 100
-
- // Format with 2 decimal places
- const formatted = rounded.toFixed(2)
+import { formatNumber } from './format-number'
- // Remove unnecessary trailing zeros
- // .00 -> remove entirely (156.00 -> 156)
- // .X0 -> remove trailing 0 (156.30 -> 156.3)
- return formatted.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')
+export function fmtPct(n: number) {
+ return formatNumber(n)
}
diff --git a/src/codegen/utils/format-number.ts b/src/codegen/utils/format-number.ts
new file mode 100644
index 0000000..1503da0
--- /dev/null
+++ b/src/codegen/utils/format-number.ts
@@ -0,0 +1,12 @@
+/**
+ * Round to 2 decimal places and remove unnecessary trailing zeros
+ * Examples:
+ * 156.00 -> "156"
+ * 156.30 -> "156.3"
+ * 156.35 -> "156.35"
+ */
+export function formatNumber(n: number): string {
+ const rounded = Math.round(n * 100) / 100
+ const formatted = rounded.toFixed(2)
+ return formatted.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')
+}
diff --git a/src/codegen/utils/four-value-shortcut.ts b/src/codegen/utils/four-value-shortcut.ts
index c562e6a..fba3301 100644
--- a/src/codegen/utils/four-value-shortcut.ts
+++ b/src/codegen/utils/four-value-shortcut.ts
@@ -10,8 +10,6 @@ export function fourValueShortcut(
return addPx(first, '0')
if (first === third && second === fourth)
return `${addPx(first, '0')} ${addPx(second, '0')}`
- if (first === third && second === fourth)
- return `${addPx(first, '0')} ${addPx(second, '0')}`
if (second === fourth)
return `${addPx(first, '0')} ${addPx(second, '0')} ${addPx(third, '0')}`
return `${addPx(first, '0')} ${addPx(second, '0')} ${addPx(third, '0')} ${addPx(fourth, '0')}`
diff --git a/src/codegen/utils/paint-to-css.ts b/src/codegen/utils/paint-to-css.ts
index a729cf4..77e8fe3 100644
--- a/src/codegen/utils/paint-to-css.ts
+++ b/src/codegen/utils/paint-to-css.ts
@@ -11,6 +11,53 @@ interface Point {
y: number
}
+/**
+ * Process a single gradient stop to get its color string with opacity applied
+ */
+async function processGradientStopColor(
+ stop: ColorStop,
+ opacity: number,
+): Promise {
+ if (stop.boundVariables?.color) {
+ const variable = await figma.variables.getVariableByIdAsync(
+ stop.boundVariables.color.id as string,
+ )
+ if (variable?.name) {
+ const tokenName = `$${toCamel(variable.name)}`
+ const finalAlpha = stop.color.a * opacity
+
+ if (finalAlpha < 1) {
+ const transparentPercent = fmtPct((1 - finalAlpha) * 100)
+ return `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)`
+ }
+ return tokenName
+ }
+ }
+
+ const colorWithOpacity = figma.util.rgba({
+ ...stop.color,
+ a: stop.color.a * opacity,
+ })
+ return optimizeHex(rgbaToHex(colorWithOpacity))
+}
+
+/**
+ * Map gradient stops to CSS color strings with positions
+ */
+async function mapSimpleGradientStops(
+ stops: readonly ColorStop[],
+ opacity: number,
+ positionMultiplier: number = 100,
+): Promise {
+ const stopsArray = await Promise.all(
+ stops.map(async (stop) => {
+ const colorString = await processGradientStopColor(stop, opacity)
+ return `${colorString} ${fmtPct(stop.position * positionMultiplier)}%`
+ }),
+ )
+ return stopsArray.join(', ')
+}
+
/**
* This function converts Figma paint to CSS.
*/
@@ -65,49 +112,15 @@ async function convertDiamond(
_width: number,
_height: number,
): Promise {
- // Handle opacity & visibility:
if (!fill.visible) return 'transparent'
if (fill.opacity === 0) return 'transparent'
- // 1. Map gradient stops with opacity
- const stopsArray = await Promise.all(
- fill.gradientStops.map(async (stop) => {
- let colorString: string
- if (stop.boundVariables?.color) {
- const variable = await figma.variables.getVariableByIdAsync(
- stop.boundVariables.color.id as string,
- )
- if (variable?.name) {
- const tokenName = `$${toCamel(variable.name)}`
- const finalAlpha = stop.color.a * (fill.opacity ?? 1)
-
- if (finalAlpha < 1) {
- const transparentPercent = fmtPct((1 - finalAlpha) * 100)
- colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)`
- } else {
- colorString = tokenName
- }
- } else {
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * (fill.opacity ?? 1),
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- } else {
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * (fill.opacity ?? 1),
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- return `${colorString} ${fmtPct(stop.position * 50)}%`
- }),
+ const stops = await mapSimpleGradientStops(
+ fill.gradientStops,
+ fill.opacity ?? 1,
+ 50,
)
- const stops = stopsArray.join(', ')
- // 2. Create 4 linear gradients for diamond effect
- // Each gradient goes from corner to center
const gradients = [
`linear-gradient(to bottom right, ${stops}) bottom right / 50.1% 50.1% no-repeat`,
`linear-gradient(to bottom left, ${stops}) bottom left / 50.1% 50.1% no-repeat`,
@@ -115,7 +128,6 @@ async function convertDiamond(
`linear-gradient(to top right, ${stops}) top right / 50.1% 50.1% no-repeat`,
]
- // 3. Combine all gradients
return gradients.join(', ')
}
@@ -124,59 +136,23 @@ async function convertAngular(
width: number,
height: number,
): Promise {
- // Handle opacity & visibility:
if (!fill.visible) return 'transparent'
if (fill.opacity === 0) return 'transparent'
- // 1. Calculate actual center and start angle from gradient transform
const { center, startAngle } = _calculateAngularPositions(
fill.gradientTransform,
width,
height,
)
- // 2. Convert center to percentage values
const centerX = fmtPct((center.x / width) * 100)
const centerY = fmtPct((center.y / height) * 100)
- // 3. Map gradient stops with opacity
- const stopsArray = await Promise.all(
- fill.gradientStops.map(async (stop) => {
- let colorString: string
- if (stop.boundVariables?.color) {
- const variable = await figma.variables.getVariableByIdAsync(
- stop.boundVariables.color.id as string,
- )
- if (variable?.name) {
- const tokenName = `$${toCamel(variable.name)}`
- const finalAlpha = stop.color.a * (fill.opacity ?? 1)
-
- if (finalAlpha < 1) {
- const transparentPercent = fmtPct((1 - finalAlpha) * 100)
- colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)`
- } else {
- colorString = tokenName
- }
- } else {
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * (fill.opacity ?? 1),
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- } else {
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * (fill.opacity ?? 1),
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- return `${colorString} ${fmtPct(stop.position * 100)}%`
- }),
+ const stops = await mapSimpleGradientStops(
+ fill.gradientStops,
+ fill.opacity ?? 1,
)
- const stops = stopsArray.join(', ')
- // 4. Generate CSS conic gradient string with calculated start angle
return `conic-gradient(from ${fmtPct(startAngle)}deg at ${centerX}% ${centerY}%, ${stops})`
}
@@ -185,62 +161,25 @@ async function convertRadial(
width: number,
height: number,
): Promise {
- // Handle opacity & visibility:
if (!fill.visible) return 'transparent'
if (fill.opacity === 0) return 'transparent'
- // 1. Calculate actual center and radius from gradient transform
const { center, radiusW, radiusH } = _calculateRadialPositions(
fill.gradientTransform,
width,
height,
)
- // 2. Convert center to percentage values
const centerX = fmtPct((center.x / width) * 100)
const centerY = fmtPct((center.y / height) * 100)
-
- // 3. Calculate radius percentages for width and height separately
const radiusPercentW = fmtPct((radiusW / width) * 100)
const radiusPercentH = fmtPct((radiusH / height) * 100)
- // 4. Map gradient stops with opacity
- const stopsArray = await Promise.all(
- fill.gradientStops.map(async (stop) => {
- let colorString: string
- if (stop.boundVariables?.color) {
- const variable = await figma.variables.getVariableByIdAsync(
- stop.boundVariables.color.id as string,
- )
- if (variable?.name) {
- const tokenName = `$${toCamel(variable.name)}`
- const finalAlpha = stop.color.a * (fill.opacity ?? 1)
-
- if (finalAlpha < 1) {
- const transparentPercent = fmtPct((1 - finalAlpha) * 100)
- colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)`
- } else {
- colorString = tokenName
- }
- } else {
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * (fill.opacity ?? 1),
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- } else {
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * (fill.opacity ?? 1),
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- return `${colorString} ${fmtPct(stop.position * 100)}%`
- }),
+ const stops = await mapSimpleGradientStops(
+ fill.gradientStops,
+ fill.opacity ?? 1,
)
- const stops = stopsArray.join(', ')
- // 5. Generate CSS radial gradient string
+
return `radial-gradient(${radiusPercentW}% ${radiusPercentH}% at ${centerX}% ${centerY}%, ${stops})`
}
@@ -443,12 +382,9 @@ async function _mapGradientStops(
return await Promise.all(
stops.map(async (stop) => {
- // Calculate actual pixel position of stop in Figma space (offset)
const offsetX = figmaStartPoint.x + figmaVector.x * stop.position
const offsetY = figmaStartPoint.y + figmaVector.y * stop.position
- // Compute signed relative position along CSS gradient line (can be <0 or >1)
- // t = dot(P - start, (end - start)) / |end - start|^2
const pointFromStart = {
x: offsetX - cssStartPoint.x,
y: offsetY - cssStartPoint.y,
@@ -458,40 +394,7 @@ async function _mapGradientStops(
const relativePosition =
cssLengthSquared === 0 ? 0 : dot / cssLengthSquared
- // Check if this color stop uses a color token
- let colorString: string
- if (stop.boundVariables?.color) {
- const variable = await figma.variables.getVariableByIdAsync(
- stop.boundVariables.color.id as string,
- )
- if (variable?.name) {
- const tokenName = `$${toCamel(variable.name)}`
- // Calculate final alpha combining stop alpha and gradient opacity
- const finalAlpha = stop.color.a * opacity
-
- // Use color-mix to apply opacity to color token
- if (finalAlpha < 1) {
- const transparentPercent = fmtPct((1 - finalAlpha) * 100)
- colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)`
- } else {
- colorString = tokenName
- }
- } else {
- // Fallback to computed color with opacity
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * opacity,
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
- } else {
- // Apply gradient opacity to the color stop
- const colorWithOpacity = figma.util.rgba({
- ...stop.color,
- a: stop.color.a * opacity,
- })
- colorString = optimizeHex(rgbaToHex(colorWithOpacity))
- }
+ const colorString = await processGradientStopColor(stop, opacity)
return {
position: relativePosition,
diff --git a/src/codegen/utils/props-to-str.ts b/src/codegen/utils/props-to-str.ts
index 4481c20..6ff7494 100644
--- a/src/codegen/utils/props-to-str.ts
+++ b/src/codegen/utils/props-to-str.ts
@@ -1,3 +1,40 @@
+import { isVariantPropValue } from '../responsive'
+
+/**
+ * Convert a value to its JSX string representation.
+ * Handles primitives, arrays, and objects.
+ */
+function valueToJsxString(value: unknown): string {
+ if (value === null) return 'null'
+ if (value === undefined) return 'undefined'
+ if (typeof value === 'string') return `"${value}"`
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
+ }
+ if (Array.isArray(value)) {
+ const items = value.map((item) => valueToJsxString(item))
+ return `[${items.join(', ')}]`
+ }
+ if (typeof value === 'object') {
+ return JSON.stringify(value)
+ }
+ return String(value)
+}
+
+/**
+ * Format a VariantPropValue as JSX: { scroll: [...], default: [...] }[status]
+ */
+function formatVariantPropValue(variantProp: {
+ variantKey: string
+ values: Record
+}): string {
+ const entries = Object.entries(variantProp.values)
+ const parts = entries.map(([variant, value]) => {
+ return `${variant}: ${valueToJsxString(value)}`
+ })
+ return `{ ${parts.join(', ')} }[${variantProp.variantKey}]`
+}
+
export function propsToString(props: Record) {
const sorted = Object.entries(props).sort((a, b) => {
const isAUpper = /^[A-Z]/.test(a[0])
@@ -9,6 +46,10 @@ export function propsToString(props: Record) {
const parts = sorted.map(([key, value]) => {
if (typeof value === 'boolean') return `${key}${value ? '' : `={${value}}`}`
+ // Handle VariantPropValue
+ if (isVariantPropValue(value)) {
+ return `${key}={${formatVariantPropValue(value)}}`
+ }
if (typeof value === 'object')
return `${key}={${JSON.stringify(value, null, 2)}}`
// Special handling for animationName with keyframes function
@@ -36,7 +77,9 @@ export function propsToString(props: Record) {
const separator =
Object.keys(props).length >= 5 ||
- Object.values(props).some((value) => typeof value === 'object')
+ Object.values(props).some(
+ (value) => typeof value === 'object' && !isVariantPropValue(value),
+ )
? '\n'
: ' '
return parts.join(separator)