diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap
index 4cc369d..1232b6d 100644
--- a/src/__tests__/__snapshots__/code.test.ts.snap
+++ b/src/__tests__/__snapshots__/code.test.ts.snap
@@ -3,41 +3,14 @@
exports[`registerCodegen should register codegen 1`] = `
[
{
- "code":
-"export function Test() {
- return
-}"
-,
+ "code": "",
"language": "TYPESCRIPT",
- "title": "Test - Components",
- },
- {
- "code":
-"mkdir -p src/components
-
-echo 'import { Box } from \\'@devup-ui/react\\'
-
-export function Test() {
- return
-}' > src/components/Test.tsx"
-,
- "language": "BASH",
- "title": "Test - Components CLI (Bash)",
+ "title": "Usage",
},
{
- "code":
-"New-Item -ItemType Directory -Force -Path src\\components | Out-Null
-
-@'
-import { Box } from '@devup-ui/react'
-
-export function Test() {
- return
-}
-'@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8"
-,
- "language": "BASH",
- "title": "Test - Components CLI (PowerShell)",
+ "code": "",
+ "language": "TYPESCRIPT",
+ "title": "Test",
},
]
`;
diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts
index 27f8f68..a3458b8 100644
--- a/src/__tests__/code.test.ts
+++ b/src/__tests__/code.test.ts
@@ -409,7 +409,7 @@ describe('registerCodegen with viewport variant', () => {
typeof r === 'object' &&
r !== null &&
'title' in r &&
- (r as { title: string }).title.includes('Responsive'),
+ (r as { title: string }).title.endsWith('- Components'),
)
expect(responsiveResult).toBeDefined()
})
@@ -494,7 +494,7 @@ describe('registerCodegen with viewport variant', () => {
typeof r === 'object' &&
r !== null &&
'title' in r &&
- (r as { title: string }).title.includes('Responsive'),
+ (r as { title: string }).title.endsWith('- Components'),
)
expect(responsiveResult).toBeDefined()
@@ -505,6 +505,118 @@ describe('registerCodegen with viewport variant', () => {
}
})
+ it('should generate responsive component with multiple non-viewport variants (size + varient)', 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 two non-viewport variants: size and varient
+ const componentSetNode = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ visible: true,
+ componentPropertyDefinitions: {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'md',
+ variantOptions: ['sm', 'md'],
+ },
+ varient: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'white'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'size=sm, varient=primary',
+ visible: true,
+ variantProperties: { size: 'sm', varient: 'primary' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 100,
+ height: 40,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=md, varient=primary',
+ visible: true,
+ variantProperties: { size: 'md', varient: 'primary' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 200,
+ height: 50,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=sm, varient=white',
+ visible: true,
+ variantProperties: { size: 'sm', varient: 'white' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 100,
+ height: 40,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=md, varient=white',
+ visible: true,
+ variantProperties: { size: 'md', varient: 'white' },
+ children: [],
+ layoutMode: 'HORIZONTAL',
+ width: 200,
+ height: 50,
+ },
+ ],
+ defaultVariant: {
+ type: 'COMPONENT',
+ name: 'size=md, varient=primary',
+ visible: true,
+ variantProperties: { size: 'md', varient: 'primary' },
+ 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.endsWith('- Components'),
+ )
+ expect(responsiveResult).toBeDefined()
+
+ // The generated code should include BOTH variant keys in the interface
+ const resultWithCode = responsiveResult as { code: string } | undefined
+ if (resultWithCode?.code) {
+ expect(resultWithCode.code).toContain('size')
+ expect(resultWithCode.code).toContain('varient')
+ }
+ })
+
it('should generate responsive code for node with parent SECTION', async () => {
let capturedHandler: CodegenHandler | null = null
@@ -576,7 +688,7 @@ describe('registerCodegen with viewport variant', () => {
typeof r === 'object' &&
r !== null &&
'title' in r &&
- (r as { title: string }).title.includes('Responsive'),
+ (r as { title: string }).title.endsWith('- Responsive'),
)
expect(responsiveResult).toBeDefined()
})
@@ -601,6 +713,33 @@ describe('registerCodegen with viewport variant', () => {
expect(capturedHandler).not.toBeNull()
if (capturedHandler === null) throw new Error('Handler not captured')
+ // Create a nested custom component (NestedIcon) that CustomButton references.
+ // When CustomButton's code renders , generateImportStatements
+ // will extract it as a custom import — covering the customImports loop.
+ const nestedIconComponent = {
+ type: 'COMPONENT',
+ name: 'NestedIcon',
+ visible: true,
+ children: [],
+ width: 16,
+ height: 16,
+ layoutMode: 'NONE',
+ componentPropertyDefinitions: {},
+ variantProperties: {} as Record,
+ reactions: [],
+ parent: null,
+ }
+
+ // INSTANCE of NestedIcon, placed inside CustomButton variants
+ const nestedIconInstance = {
+ type: 'INSTANCE',
+ name: 'NestedIcon',
+ visible: true,
+ width: 16,
+ height: 16,
+ getMainComponentAsync: async () => nestedIconComponent,
+ }
+
// Create a custom component that will be referenced
const customComponent = {
type: 'COMPONENT',
@@ -611,6 +750,7 @@ describe('registerCodegen with viewport variant', () => {
height: 40,
layoutMode: 'NONE',
componentPropertyDefinitions: {},
+ variantProperties: {} as Record,
parent: null,
}
@@ -624,36 +764,93 @@ describe('registerCodegen with viewport variant', () => {
getMainComponentAsync: async () => customComponent,
}
- // Create a COMPONENT that contains the INSTANCE
- const componentNode = {
+ // Create COMPONENT variants that the instance references.
+ // Each variant contains a NestedIcon INSTANCE child — this causes
+ // the generated component code to include .
+ const componentVariant1 = {
type: 'COMPONENT',
- name: 'MyComponent',
+ name: 'CustomButton',
visible: true,
- children: [instanceNode],
- width: 200,
- height: 100,
- layoutMode: 'VERTICAL',
+ children: [
+ {
+ ...nestedIconInstance,
+ name: 'NestedIcon',
+ parent: null as unknown,
+ },
+ ],
+ width: 100,
+ height: 40,
+ layoutMode: 'HORIZONTAL',
componentPropertyDefinitions: {},
reactions: [],
+ variantProperties: { size: 'md' },
parent: null,
- } as unknown as SceneNode
+ }
- // Create COMPONENT_SET parent with proper children array
+ const componentVariant2 = {
+ type: 'COMPONENT',
+ name: 'CustomButton',
+ visible: true,
+ children: [
+ {
+ ...nestedIconInstance,
+ name: 'NestedIcon',
+ parent: null as unknown,
+ },
+ ],
+ width: 100,
+ height: 40,
+ layoutMode: 'HORIZONTAL',
+ componentPropertyDefinitions: {},
+ reactions: [],
+ variantProperties: { size: 'lg' },
+ parent: null,
+ }
+
+ // Create COMPONENT_SET parent with a variant key so Components tab is generated
const componentSetNode = {
type: 'COMPONENT_SET',
- name: 'MyComponentSet',
- componentPropertyDefinitions: {},
- children: [componentNode],
- defaultVariant: componentNode,
+ name: 'CustomButton',
+ componentPropertyDefinitions: {
+ size: {
+ type: 'VARIANT',
+ variantOptions: ['md', 'lg'],
+ },
+ },
+ children: [componentVariant1, componentVariant2],
+ defaultVariant: componentVariant1,
reactions: [],
}
- // Set parent reference
- ;(componentNode as { parent: unknown }).parent = componentSetNode
+ // Set parent references
+ ;(componentVariant1 as { parent: unknown }).parent = componentSetNode
+ ;(componentVariant2 as { parent: unknown }).parent = componentSetNode
+ for (const variant of [componentVariant1, componentVariant2]) {
+ for (const child of variant.children) {
+ ;(child as { parent: unknown }).parent = variant
+ }
+ }
+ ;(customComponent as { parent: unknown }).parent = componentSetNode
+ ;(customComponent as { variantProperties: unknown }).variantProperties = {
+ size: 'md',
+ }
+
+ // Create a FRAME that contains the INSTANCE (not a COMPONENT)
+ const frameNode = {
+ type: 'FRAME',
+ name: 'MyFrame',
+ visible: true,
+ children: [instanceNode],
+ width: 200,
+ height: 100,
+ layoutMode: 'VERTICAL',
+ reactions: [],
+ parent: null,
+ } as unknown as SceneNode
const handler = capturedHandler as CodegenHandler
const result = await handler({
- node: componentNode,
+ node: frameNode,
language: 'devup-ui',
})
@@ -676,19 +873,17 @@ describe('registerCodegen with viewport variant', () => {
expect(bashCLI).toBeDefined()
expect(powershellCLI).toBeDefined()
- // Check that custom component import is included (bash escapes quotes)
+ // Check that custom component file is included in CLI output
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\\'",
- )
+ expect(bashCode).toContain('CustomButton')
+ expect(bashCode).toContain('src/components/CustomButton.tsx')
}
if (powershellCode) {
- expect(powershellCode).toContain(
- "import { CustomButton } from '@/components/CustomButton'",
- )
+ expect(powershellCode).toContain('CustomButton')
+ expect(powershellCode).toContain('src\\components\\CustomButton.tsx')
}
})
@@ -793,18 +988,17 @@ describe('registerCodegen with viewport variant', () => {
typeof r === 'object' &&
r !== null &&
'title' in r &&
- (r as { title: string }).title === 'MyFrame - Components Responsive',
+ (r as { title: string }).title === 'MyFrame - Components',
)
expect(responsiveResult).toBeDefined()
- // Should also include CLI results for Components Responsive
+ // Should also include CLI results for Components
const bashCLI = result.find(
(r: unknown) =>
typeof r === 'object' &&
r !== null &&
'title' in r &&
- (r as { title: string }).title ===
- 'MyFrame - Components Responsive CLI (Bash)',
+ (r as { title: string }).title === 'MyFrame - Components CLI (Bash)',
)
expect(bashCLI).toBeDefined()
@@ -814,8 +1008,472 @@ describe('registerCodegen with viewport variant', () => {
r !== null &&
'title' in r &&
(r as { title: string }).title ===
- 'MyFrame - Components Responsive CLI (PowerShell)',
+ 'MyFrame - Components CLI (PowerShell)',
)
expect(powershellCLI).toBeDefined()
})
})
+
+describe('generateComponentUsage', () => {
+ it('should generate usage for COMPONENT without variant props', () => {
+ const node = {
+ type: 'COMPONENT',
+ name: 'MyButton',
+ variantProperties: null,
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should generate usage for COMPONENT with variant props', () => {
+ const node = {
+ type: 'COMPONENT',
+ name: 'MyButton',
+ variantProperties: { variant: 'primary', size: 'lg' },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should filter reserved variant keys for COMPONENT', () => {
+ const node = {
+ type: 'COMPONENT',
+ name: 'MyButton',
+ variantProperties: {
+ variant: 'primary',
+ viewport: 'mobile',
+ effect: 'hover',
+ },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should generate usage for COMPONENT in COMPONENT_SET', () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'ButtonSet',
+ }
+ const node = {
+ type: 'COMPONENT',
+ name: 'variant=primary, size=lg',
+ variantProperties: { variant: 'primary', size: 'lg' },
+ parent: componentSet,
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should generate usage for COMPONENT_SET with defaults', () => {
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ componentPropertyDefinitions: {
+ variant: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'secondary'],
+ },
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'md',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should filter reserved variant keys for COMPONENT_SET', () => {
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ componentPropertyDefinitions: {
+ variant: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'secondary'],
+ },
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should sanitize COMPONENT_SET property names with hash suffixes', () => {
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ componentPropertyDefinitions: {
+ 'variant#123:456': {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'secondary'],
+ },
+ },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should skip non-VARIANT properties for COMPONENT_SET', () => {
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ componentPropertyDefinitions: {
+ variant: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'secondary'],
+ },
+ hasIcon: {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ icon: {
+ type: 'INSTANCE_SWAP',
+ defaultValue: 'some-id',
+ },
+ },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should generate usage for COMPONENT_SET without componentPropertyDefinitions', () => {
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ componentPropertyDefinitions: undefined,
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+
+ it('should return null for non-component nodes', () => {
+ const node = {
+ type: 'FRAME',
+ name: 'MyFrame',
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBeNull()
+ })
+
+ it('should generate usage with no props when COMPONENT_SET has only reserved variants', () => {
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'MyButton',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ defaultValue: 'desktop',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ } as unknown as SceneNode
+
+ const result = codeModule.generateComponentUsage(node)
+ expect(result).toBe('')
+ })
+})
+
+describe('registerCodegen with usage output', () => {
+ type CodegenHandler = (event: {
+ node: SceneNode
+ language: string
+ }) => Promise
+
+ it('should generate usage for INSTANCE node', 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 mainComponent = {
+ type: 'COMPONENT',
+ name: 'PrimaryButton',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'PrimaryButton',
+ visible: true,
+ componentProperties: {
+ 'variant#123:456': { type: 'VARIANT', value: 'primary' },
+ 'size#789:012': { type: 'VARIANT', value: 'lg' },
+ },
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as SceneNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: instanceNode,
+ language: 'devup-ui',
+ })
+
+ const usageResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title === 'Usage',
+ )
+ expect(usageResult).toBeDefined()
+
+ const usageCode = (usageResult as { code: string }).code
+ expect(usageCode).toContain(' {
+ 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 mainComponent = {
+ type: 'COMPONENT',
+ name: 'AbsButton',
+ children: [],
+ visible: true,
+ } as unknown as ComponentNode
+
+ const parent = {
+ type: 'FRAME',
+ name: 'Parent',
+ children: [] as unknown[],
+ visible: true,
+ width: 500,
+ }
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'AbsButton',
+ visible: true,
+ width: 100,
+ height: 50,
+ x: 10,
+ y: 20,
+ layoutPositioning: 'ABSOLUTE',
+ constraints: {
+ horizontal: 'MIN',
+ vertical: 'MIN',
+ },
+ componentProperties: {
+ 'variant#1:2': { type: 'VARIANT', value: 'secondary' },
+ },
+ getMainComponentAsync: async () => mainComponent,
+ parent,
+ } as unknown as SceneNode
+
+ parent.children = [instanceNode]
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: instanceNode,
+ language: 'devup-ui',
+ })
+
+ const usageResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title === 'Usage',
+ )
+ expect(usageResult).toBeDefined()
+
+ const usageCode = (usageResult as { code: string }).code
+ // Should show clean component usage without position wrapper
+ expect(usageCode).toContain(' {
+ 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: 'MyButton',
+ componentPropertyDefinitions: {},
+ children: [] as unknown[],
+ defaultVariant: null as unknown,
+ }
+
+ const componentNode = {
+ type: 'COMPONENT',
+ name: 'variant=primary',
+ visible: true,
+ variantProperties: { variant: 'primary' },
+ children: [],
+ width: 100,
+ height: 40,
+ layoutMode: 'NONE',
+ componentPropertyDefinitions: {},
+ parent: componentSetNode,
+ reactions: [],
+ } as unknown as SceneNode
+
+ componentSetNode.children = [componentNode]
+ componentSetNode.defaultVariant = componentNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: componentNode,
+ language: 'devup-ui',
+ })
+
+ const usageResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title === 'Usage',
+ )
+ expect(usageResult).toBeDefined()
+
+ const usageCode = (usageResult as { code: string }).code
+ expect(usageCode).toBe('')
+ })
+
+ it('should generate usage for COMPONENT_SET node', 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: 'MyButton',
+ visible: true,
+ componentPropertyDefinitions: {
+ variant: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'secondary'],
+ },
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'md',
+ variantOptions: ['sm', 'md', 'lg'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'variant=primary, size=md',
+ visible: true,
+ variantProperties: { variant: 'primary', size: 'md' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 100,
+ height: 40,
+ },
+ ],
+ defaultVariant: {
+ type: 'COMPONENT',
+ name: 'variant=primary, size=md',
+ visible: true,
+ variantProperties: { variant: 'primary', size: 'md' },
+ children: [],
+ },
+ } as unknown as SceneNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({
+ node: componentSetNode,
+ language: 'devup-ui',
+ })
+
+ const usageResult = result.find(
+ (r: unknown) =>
+ typeof r === 'object' &&
+ r !== null &&
+ 'title' in r &&
+ (r as { title: string }).title === 'Usage',
+ )
+ expect(usageResult).toBeDefined()
+
+ const usageCode = (usageResult as { code: string }).code
+ expect(usageCode).toBe('')
+ })
+})
diff --git a/src/code-impl.ts b/src/code-impl.ts
index 0ad1f86..db0b896 100644
--- a/src/code-impl.ts
+++ b/src/code-impl.ts
@@ -5,8 +5,12 @@ import {
} from './codegen/Codegen'
import { resetGetPropsCache } from './codegen/props'
import { resetChildAnimationCache } from './codegen/props/reaction'
-import { resetSelectorPropsCache } from './codegen/props/selector'
+import {
+ resetSelectorPropsCache,
+ sanitizePropertyName,
+} from './codegen/props/selector'
import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
+import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props'
import { nodeProxyTracker } from './codegen/utils/node-proxy'
import { perfEnd, perfReport, perfReset, perfStart } from './codegen/utils/perf'
import { resetVariableCache } from './codegen/utils/variable-cache'
@@ -126,6 +130,44 @@ function generatePowerShellCLI(
return commands.join('\n')
}
+export function generateComponentUsage(node: SceneNode): string | null {
+ const componentName = getComponentName(node)
+
+ if (node.type === 'COMPONENT') {
+ const variantProps = (node as ComponentNode).variantProperties
+ if (!variantProps) return `<${componentName} />`
+
+ const entries: [string, string][] = []
+ for (const [key, value] of Object.entries(variantProps)) {
+ if (!isReservedVariantKey(key)) {
+ entries.push([key, value])
+ }
+ }
+
+ if (entries.length === 0) return `<${componentName} />`
+ const propsStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ')
+ return `<${componentName} ${propsStr} />`
+ }
+
+ if (node.type === 'COMPONENT_SET') {
+ const defs = (node as ComponentSetNode).componentPropertyDefinitions
+ if (!defs) return `<${componentName} />`
+
+ const entries: [string, string][] = []
+ for (const [key, def] of Object.entries(defs)) {
+ if (def.type === 'VARIANT' && !isReservedVariantKey(key)) {
+ entries.push([sanitizePropertyName(key), String(def.defaultValue)])
+ }
+ }
+
+ if (entries.length === 0) return `<${componentName} />`
+ const propsStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ')
+ return `<${componentName} ${propsStr} />`
+ }
+
+ return null
+}
+
const debug = true
export function registerCodegen(ctx: typeof figma) {
@@ -161,6 +203,12 @@ export function registerCodegen(ctx: typeof figma) {
> = []
if (node.type === 'COMPONENT_SET') {
const componentName = getComponentName(node)
+ // Reset the global build tree cache so that each variant's Codegen
+ // instance runs doBuildTree fresh — this ensures addComponentTree fires
+ // and BOOLEAN condition fields are populated on children.
+ // Without this, cached trees from codegen.run() above would cause
+ // buildTree to return early, skipping addComponentTree entirely.
+ resetGlobalBuildTreeCache()
t = perfStart()
responsiveComponentsCodes =
await ResponsiveCodegen.generateVariantResponsiveComponents(
@@ -171,10 +219,18 @@ export function registerCodegen(ctx: typeof figma) {
}
// Generate responsive codes for components extracted from the page
+ // Skip when the selected node itself is a COMPONENT or COMPONENT_SET,
+ // because the self-referencing componentTree would trigger the parent
+ // COMPONENT_SET to be fully expanded — producing ComponentSet-level output
+ // when the user only wants to see their selected variant.
let componentsResponsiveCodes: ReadonlyArray<
readonly [string, string]
> = []
- if (componentsCodes.length > 0) {
+ if (
+ componentsCodes.length > 0 &&
+ node.type !== 'COMPONENT' &&
+ node.type !== 'COMPONENT_SET'
+ ) {
const componentNodes = codegen.getComponentNodes()
const processedComponentSets = new Set()
const responsiveResults: Array = []
@@ -190,6 +246,8 @@ export function registerCodegen(ctx: typeof figma) {
if (parentSet && !processedComponentSets.has(parentSet.id)) {
processedComponentSets.add(parentSet.id)
const componentName = getComponentName(parentSet)
+ // Reset global cache so addComponentTree fires for BOOLEAN conditions
+ resetGlobalBuildTreeCache()
t = perfStart()
const responsiveCodes =
await ResponsiveCodegen.generateVariantResponsiveComponents(
@@ -273,76 +331,83 @@ export function registerCodegen(ctx: typeof figma) {
)
}
- return [
- ...(node.type === 'COMPONENT' ||
+ // Generate usage snippet for component-type nodes
+ const isComponentType =
+ node.type === 'COMPONENT' ||
node.type === 'COMPONENT_SET' ||
node.type === 'INSTANCE'
- ? []
- : [
+ const usageResults: {
+ title: string
+ language: 'TYPESCRIPT'
+ code: string
+ }[] = []
+ if (node.type === 'INSTANCE') {
+ // For INSTANCE: extract clean component usage from the tree
+ // (tree already has the correct component name from getMainComponentAsync)
+ const tree = await codegen.getTree()
+ const componentTree = tree.isComponent
+ ? tree
+ : tree.children.find((c) => c.isComponent)
+ if (componentTree) {
+ usageResults.push({
+ title: 'Usage',
+ language: 'TYPESCRIPT',
+ code: Codegen.renderTree(componentTree, 0),
+ })
+ }
+ } else if (
+ node.type === 'COMPONENT' ||
+ node.type === 'COMPONENT_SET'
+ ) {
+ const usage = generateComponentUsage(node)
+ if (usage) {
+ usageResults.push({
+ title: 'Usage',
+ language: 'TYPESCRIPT',
+ code: usage,
+ })
+ }
+ }
+
+ const allComponentsCodes = [
+ ...componentsResponsiveCodes,
+ ...responsiveComponentsCodes,
+ ]
+
+ // For COMPONENT nodes, show both the single-variant code AND Usage.
+ // For COMPONENT_SET and INSTANCE, show only Usage.
+ // For all other types, show the main code.
+ const showMainCode = !isComponentType || node.type === 'COMPONENT'
+
+ return [
+ ...usageResults,
+ ...(showMainCode
+ ? [
{
title: node.name,
language: 'TYPESCRIPT',
code: codegen.getCode(),
} as const,
- ]),
- ...(componentsCodes.length > 0
- ? ([
- {
- title: `${node.name} - Components`,
- language: 'TYPESCRIPT',
- code: componentsCodes.map((code) => code[1]).join('\n\n'),
- },
- {
- title: `${node.name} - Components CLI (Bash)`,
- language: 'BASH',
- code: generateBashCLI(componentsCodes),
- },
- {
- title: `${node.name} - Components CLI (PowerShell)`,
- language: 'BASH',
- code: generatePowerShellCLI(componentsCodes),
- },
- ] as const)
- : []),
- ...(componentsResponsiveCodes.length > 0
- ? [
- {
- title: `${node.name} - Components Responsive`,
- language: 'TYPESCRIPT' as const,
- code: componentsResponsiveCodes
- .map((code) => code[1])
- .join('\n\n'),
- },
- {
- title: `${node.name} - Components Responsive CLI (Bash)`,
- language: 'BASH' as const,
- code: generateBashCLI(componentsResponsiveCodes),
- },
- {
- title: `${node.name} - Components Responsive CLI (PowerShell)`,
- language: 'BASH' as const,
- code: generatePowerShellCLI(componentsResponsiveCodes),
- },
]
: []),
- ...(responsiveComponentsCodes.length > 0
+ ...(allComponentsCodes.length > 0
? [
{
- title: `${node.name} - Components Responsive`,
+ title: `${node.name} - Components`,
language: 'TYPESCRIPT' as const,
- code: responsiveComponentsCodes
+ code: allComponentsCodes
.map((code) => code[1])
.join('\n\n'),
},
{
- title: `${node.name} - Components Responsive CLI (Bash)`,
+ title: `${node.name} - Components CLI (Bash)`,
language: 'BASH' as const,
- code: generateBashCLI(responsiveComponentsCodes),
+ code: generateBashCLI(allComponentsCodes),
},
{
- title: `${node.name} - Components Responsive CLI (PowerShell)`,
+ title: `${node.name} - Components CLI (PowerShell)`,
language: 'BASH' as const,
- code: generatePowerShellCLI(responsiveComponentsCodes),
+ code: generatePowerShellCLI(allComponentsCodes),
},
]
: []),
diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts
index 14fe3ea..b8d91e5 100644
--- a/src/codegen/Codegen.ts
+++ b/src/codegen/Codegen.ts
@@ -154,6 +154,18 @@ export class Codegen {
return this.code
}
+ /**
+ * Get the component tree built by addComponentTree for this node.
+ * Unlike getTree(), which skips invisible children in buildTree(),
+ * this tree includes ALL children with BOOLEAN conditions and
+ * INSTANCE_SWAP slot placeholders preserved.
+ * Returns undefined if no component tree was built (non-COMPONENT nodes).
+ */
+ getComponentTree(): ComponentTree | undefined {
+ const nodeId = this.node.id || this.node.name
+ return this.componentTrees.get(nodeId)
+ }
+
getComponentsCodes() {
const result: Array = []
for (const { node, code, variants } of this.components.values()) {
@@ -252,6 +264,23 @@ export class Codegen {
private async doBuildTree(node: SceneNode): Promise {
const tBuild = perfStart()
+
+ // Handle COMPONENT_SET or COMPONENT — fire addComponentTree BEFORE any early returns
+ // (e.g., asset detection) so that BOOLEAN conditions and INSTANCE_SWAP slots are always
+ // detected on children, even when the COMPONENT itself is classified as an asset.
+ if (
+ (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') &&
+ ((this.node.type === 'COMPONENT_SET' &&
+ node === this.node.defaultVariant) ||
+ this.node.type === 'COMPONENT')
+ ) {
+ this.pendingComponentTrees.push(
+ this.addComponentTree(
+ node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
+ ),
+ )
+ }
+
// Handle asset nodes (images/SVGs)
const assetNode = checkAssetNode(node)
if (assetNode) {
@@ -263,6 +292,7 @@ export class Codegen {
props.maskImage = buildCssUrl(props.src as string)
props.maskRepeat = 'no-repeat'
props.maskSize = 'contain'
+ props.maskPos = 'center'
props.bg = maskColor
delete props.src
}
@@ -339,20 +369,6 @@ export class Codegen {
// Fire getProps early for non-INSTANCE nodes — it runs while we process children.
const propsPromise = getProps(node)
- // Handle COMPONENT_SET or COMPONENT - add to componentTrees (fire-and-forget)
- if (
- (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') &&
- ((this.node.type === 'COMPONENT_SET' &&
- node === this.node.defaultVariant) ||
- this.node.type === 'COMPONENT')
- ) {
- this.pendingComponentTrees.push(
- this.addComponentTree(
- node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
- ),
- )
- }
-
// Build children sequentially — Figma's single-threaded IPC means
// concurrent subtree builds add overhead without improving throughput,
// and sequential order maximizes cache hits for shared nodes.
diff --git a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
index c8d6f68..f0230c1 100644
--- a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
+++ b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
@@ -35,6 +35,7 @@ exports[`Codegen renders svg asset with same color mask 1`] = `
bg="#F00"
boxSize="24px"
maskImage="url(/icons/MaskIcon.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>"
@@ -45,6 +46,7 @@ exports[`Codegen renders svg asset with children same color 1`] = `
bg="#F00"
boxSize="24px"
maskImage="url(/icons/GroupIcon.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>"
@@ -61,6 +63,7 @@ exports[`Codegen renders nested svg asset with 3 solid fill boxes in frame 1`] =
bg="#F00"
boxSize="24px"
maskImage="url(/icons/NestedIcon.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>"
@@ -1658,6 +1661,7 @@ exports[`render real world component real world $ 19`] = `
bg="#FFF"
boxSize="20px"
maskImage="url(/icons/arrow.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>
@@ -1895,7 +1899,15 @@ exports[`render real world component real world $ 32`] = `
exports[`render real world component real world $ 33`] = `
"export function Circle() {
- return
+ return (
+
+ )
}"
`;
@@ -1934,6 +1946,7 @@ exports[`render real world component real world $ 36`] = `
bg="#FFF"
borderTop="solid 1px #000"
maskImage="url(/icons/border.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>
@@ -2144,6 +2157,7 @@ exports[`render real world component real world $ 47`] = `
aspectRatio="1"
bg="$caption"
maskImage="url(/icons/recommend.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pb="2.96px"
@@ -2161,6 +2175,7 @@ exports[`render real world component real world $ 48`] = `
@@ -2981,6 +2998,7 @@ exports[`render real world component real world $ 78`] = `
bg="$gray300"
boxSize="24px"
maskImage="url(/icons/ic:round-arrow-left.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
transform="rotate(180deg)"
@@ -3009,6 +3027,7 @@ exports[`render real world component real world $ 78`] = `
bg="$gray300"
boxSize="24px"
maskImage="url(/icons/ic:round-arrow-left.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
transform="rotate(180deg)"
@@ -3037,6 +3056,7 @@ exports[`render real world component real world $ 78`] = `
bg="$gray300"
boxSize="24px"
maskImage="url(/icons/ic:round-arrow-left.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
transform="rotate(180deg)"
@@ -3050,19 +3070,43 @@ exports[`render real world component real world $ 78`] = `
exports[`render real world component real world $ 79`] = `
"export function 다양한도형() {
- return
+ return (
+
+ )
}"
`;
exports[`render real world component real world $ 80`] = `
"export function 다양한도형() {
- return
+ return (
+
+ )
}"
`;
exports[`render real world component real world $ 81`] = `
"export function 다양한도형() {
- return
+ return (
+
+ )
}"
`;
@@ -3129,6 +3173,7 @@ exports[`render real world component real world $ 91`] = `
bg="$textLight"
boxSize="24px"
maskImage="url(/icons/back.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pb="5px"
@@ -3151,6 +3196,7 @@ exports[`render real world component real world $ 92`] = `
bg="$primary"
boxSize="20px"
maskImage="url(/icons/plus.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pb="2.29px"
@@ -3182,6 +3228,7 @@ exports[`render real world component real world $ 94`] = `
bg="#000"
boxSize="28px"
maskImage="url(/icons/cog.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pb="2.33px"
@@ -3213,6 +3260,7 @@ exports[`render real world component real world $ 96`] = `
bg="#000"
boxSize="28px"
maskImage="url(/icons/cog.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pb="2.33px"
@@ -3495,6 +3543,7 @@ exports[`render real world component real world $ 106`] = `
bg="#B1874F"
h="104px"
maskImage="url(/icons/path1208.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
w="200px"
@@ -3627,6 +3676,7 @@ exports[`render real world component real world $ 107`] = `
bg="#B1874F"
h="52px"
maskImage="url(/icons/path1208.svg)"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
w="100px"
@@ -3781,6 +3831,7 @@ exports[`render real world component real world $ 108`] = `
bg="$gold"
h="64px"
maskImage="url('/icons/Group 27.svg')"
+ maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
w="102.17px"
diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts
index d62400d..321a38e 100644
--- a/src/codegen/__tests__/codegen-viewport.test.ts
+++ b/src/codegen/__tests__/codegen-viewport.test.ts
@@ -1,5 +1,8 @@
-import { afterAll, describe, expect, test } from 'bun:test'
-import { Codegen } from '../Codegen'
+import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
+import { resetTextStyleCache } from '../../utils'
+import { Codegen, resetGlobalBuildTreeCache } from '../Codegen'
+import { resetGetPropsCache } from '../props'
+import { resetSelectorPropsCache } from '../props/selector'
import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
// Mock figma global
@@ -42,7 +45,18 @@ import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
},
} as unknown as typeof figma
+beforeEach(() => {
+ resetGlobalBuildTreeCache()
+ resetGetPropsCache()
+ resetSelectorPropsCache()
+ resetTextStyleCache()
+})
+
afterAll(() => {
+ resetGlobalBuildTreeCache()
+ resetGetPropsCache()
+ resetSelectorPropsCache()
+ resetTextStyleCache()
;(globalThis as { figma?: unknown }).figma = undefined
})
@@ -697,4 +711,839 @@ describe('Codegen effect-only COMPONENT_SET', () => {
// Should have _hover props
expect(generatedCode).toContain('_hover')
})
+
+ test('generates BOOLEAN conditions on children in multi-variant ComponentSet', async () => {
+ // Simulates a ComponentSet with:
+ // - size: lg, tag (VARIANT)
+ // - varient: primary, white (VARIANT)
+ // - leftIcon: boolean (BOOLEAN) — controls Icon child visibility
+ //
+ // Icon child exists in lg+primary and lg+white (with leftIcon condition)
+ // but NOT in tag variants. This is a BOOLEAN-controlled partial child.
+
+ function createFrameChild(
+ id: string,
+ name: string,
+ overrides: Record = {},
+ ): SceneNode {
+ return {
+ type: 'FRAME',
+ id,
+ name,
+ visible: true,
+ children: [],
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ width: 20,
+ height: 20,
+ layoutMode: 'NONE',
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ ...overrides,
+ } as unknown as SceneNode
+ }
+
+ // lg+primary (has Icon child with BOOLEAN condition + Label child)
+ const lgPrimary = createComponentNode(
+ 'size=lg, varient=primary',
+ { size: 'lg', varient: 'primary' },
+ {
+ id: 'lg-primary',
+ children: [
+ createFrameChild('icon-lg-p', 'Icon', {
+ componentPropertyReferences: { visible: 'leftIcon#70:100' },
+ }),
+ createFrameChild('label-lg-p', 'Label'),
+ ] as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 10,
+ paddingLeft: 24,
+ paddingRight: 24,
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ )
+
+ // lg+white (has Icon child with BOOLEAN condition + Label child)
+ const lgWhite = createComponentNode(
+ 'size=lg, varient=white',
+ { size: 'lg', varient: 'white' },
+ {
+ id: 'lg-white',
+ children: [
+ createFrameChild('icon-lg-w', 'Icon', {
+ componentPropertyReferences: { visible: 'leftIcon#70:100' },
+ }),
+ createFrameChild('label-lg-w', 'Label'),
+ ] as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 10,
+ paddingLeft: 24,
+ paddingRight: 24,
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ )
+
+ // tag+primary (NO Icon, just Label)
+ const tagPrimary = createComponentNode(
+ 'size=tag, varient=primary',
+ { size: 'tag', varient: 'primary' },
+ {
+ id: 'tag-primary',
+ children: [
+ createFrameChild('label-tag-p', 'Label'),
+ ] as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 0,
+ paddingLeft: 10,
+ paddingRight: 10,
+ paddingTop: 6,
+ paddingBottom: 6,
+ },
+ )
+
+ // tag+white (NO Icon, just Label)
+ const tagWhite = createComponentNode(
+ 'size=tag, varient=white',
+ { size: 'tag', varient: 'white' },
+ {
+ id: 'tag-white',
+ children: [
+ createFrameChild('label-tag-w', 'Label'),
+ ] as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 0,
+ paddingLeft: 10,
+ paddingRight: 10,
+ paddingTop: 6,
+ paddingBottom: 6,
+ },
+ )
+
+ const allChildren = [lgPrimary, lgWhite, tagPrimary, tagWhite]
+
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'lg',
+ variantOptions: ['lg', 'tag'],
+ },
+ varient: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: ['primary', 'white'],
+ },
+ 'leftIcon#70:100': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ },
+ allChildren,
+ )
+
+ // Set parent references (critical for addComponentTree to find BOOLEAN properties)
+ for (const child of allChildren) {
+ ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet
+ if ('children' in child && child.children) {
+ for (const grandchild of child.children) {
+ ;(grandchild as unknown as { parent: SceneNode }).parent = child
+ }
+ }
+ }
+
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Button',
+ )
+
+ expect(codes.length).toBe(1)
+ const [, generatedCode] = codes[0]
+
+ // Should have leftIcon as a boolean prop
+ expect(generatedCode).toContain('leftIcon?: boolean')
+
+ // Icon child should have BOOLEAN condition prepended before variant condition
+ // The icon exists in lg+primary and lg+white but NOT tag variants
+ // So it should render as: {leftIcon && size === "lg" && }
+ expect(generatedCode).toMatch(/leftIcon\s*&&/)
+ })
+
+ test('generates BOOLEAN conditions on INSTANCE asset children in multi-variant ComponentSet', async () => {
+ // Mirrors the real Figma Button structure where:
+ // - size: lg, md, sm, tag (VARIANT)
+ // - varient: primary, white (VARIANT)
+ // - leftIcon: boolean (BOOLEAN) — controls MypageIcon child visibility
+ // - rightIcon: boolean (BOOLEAN) — controls Arrow child visibility
+ //
+ // MypageIcon and Arrow are INSTANCE children classified as SVG assets.
+ // They exist in lg, md, sm variants but NOT tag variants.
+
+ function createInstanceChild(
+ id: string,
+ name: string,
+ overrides: Record = {},
+ ): SceneNode {
+ return {
+ type: 'INSTANCE',
+ id,
+ name,
+ visible: true,
+ children: [
+ {
+ type: 'VECTOR',
+ id: `${id}-vec`,
+ name: `${name}Vector`,
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ],
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ isAsset: true,
+ width: 20,
+ height: 20,
+ ...overrides,
+ } as unknown as SceneNode
+ }
+
+ function createTextChild(
+ id: string,
+ name: string,
+ text: string,
+ overrides: Record = {},
+ ): SceneNode {
+ return {
+ type: 'TEXT',
+ id,
+ name,
+ visible: true,
+ characters: text,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ width: 50,
+ height: 20,
+ fontSize: 16,
+ fontName: { family: 'Pretendard', style: 'Bold' },
+ fontWeight: 600,
+ textAlignHorizontal: 'LEFT',
+ textAlignVertical: 'CENTER',
+ letterSpacing: { value: -2, unit: 'PERCENT' },
+ lineHeight: { unit: 'AUTO' },
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ textStyleId: '',
+ getStyledTextSegments: () => [
+ {
+ characters: text,
+ start: 0,
+ end: text.length,
+ fontSize: 16,
+ fontName: { family: 'Pretendard', style: 'Bold' },
+ fontWeight: 600,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ textDecoration: 'NONE',
+ textCase: 'ORIGINAL',
+ lineHeight: { unit: 'AUTO' },
+ letterSpacing: { value: -2, unit: 'PERCENT' },
+ textStyleId: '',
+ fillStyleId: '',
+ listOptions: { type: 'NONE' },
+ indentation: 0,
+ hyperlink: null,
+ },
+ ],
+ ...overrides,
+ } as unknown as SceneNode
+ }
+
+ // Helper to build a variant COMPONENT with MypageIcon + Text + Arrow children
+ function createVariantWithIcons(
+ size: string,
+ varient: string,
+ opts: { hasIcons: boolean; px: number; gap: number },
+ ) {
+ const id = `${size}-${varient}`
+ const children: SceneNode[] = []
+ if (opts.hasIcons) {
+ children.push(
+ createInstanceChild(`icon-${id}`, 'MypageIcon', {
+ componentPropertyReferences: {
+ visible: 'leftIcon#70:100',
+ },
+ }),
+ )
+ }
+ children.push(createTextChild(`text-${id}`, 'Label', `btn${size}`))
+ if (opts.hasIcons) {
+ children.push(
+ createInstanceChild(`arrow-${id}`, 'Arrow', {
+ componentPropertyReferences: {
+ visible: 'rightIcon#71:101',
+ },
+ }),
+ )
+ }
+
+ return createComponentNode(
+ `size=${size}, varient=${varient}`,
+ { size, varient },
+ {
+ id,
+ children: children as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: opts.gap,
+ paddingLeft: opts.px,
+ paddingRight: opts.px,
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ )
+ }
+
+ const sizes = ['lg', 'md', 'sm', 'tag']
+ const varients = ['primary', 'white']
+ const allChildren: ComponentNode[] = []
+
+ for (const size of sizes) {
+ for (const varient of varients) {
+ const hasIcons = size !== 'tag'
+ allChildren.push(
+ createVariantWithIcons(size, varient, {
+ hasIcons,
+ px: size === 'tag' ? 10 : 24,
+ gap: 10,
+ }),
+ )
+ }
+ }
+
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'lg',
+ variantOptions: sizes,
+ },
+ varient: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: varients,
+ },
+ 'leftIcon#70:100': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ 'rightIcon#71:101': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ },
+ allChildren,
+ )
+
+ // Set parent references
+ for (const child of allChildren) {
+ ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet
+ if ('children' in child && child.children) {
+ for (const grandchild of child.children) {
+ ;(grandchild as unknown as { parent: SceneNode }).parent = child
+ }
+ }
+ }
+
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Button',
+ )
+
+ expect(codes.length).toBe(1)
+ const [, generatedCode] = codes[0]
+
+ // Should have leftIcon and rightIcon as boolean props
+ expect(generatedCode).toContain('leftIcon?: boolean')
+ expect(generatedCode).toContain('rightIcon?: boolean')
+
+ // MypageIcon should have leftIcon condition
+ // Arrow should have rightIcon condition
+ expect(generatedCode).toMatch(/leftIcon\s*&&/)
+ expect(generatedCode).toMatch(/rightIcon\s*&&/)
+
+ // MypageIcon and Arrow only exist in lg, md, sm — not tag
+ // So there should also be a size condition
+ expect(generatedCode).toMatch(
+ /size\s*===\s*"lg"\s*\|\|\s*size\s*===\s*"md"\s*\|\|\s*size\s*===\s*"sm"/,
+ )
+ })
+
+ test('BOOLEAN conditions survive after codegen.run() populates globalBuildTreeCache', async () => {
+ // Regression test: In real Figma, codegen.run(componentSet) is called first,
+ // populating globalBuildTreeCache. Then generateVariantResponsiveComponents is called.
+ // Without resetting the cache, buildTree returns cached trees without firing
+ // addComponentTree, so BOOLEAN conditions are never set on children.
+
+ function createInstanceChild3(
+ id: string,
+ name: string,
+ overrides: Record = {},
+ ): SceneNode {
+ return {
+ type: 'INSTANCE',
+ id,
+ name,
+ visible: true,
+ children: [
+ {
+ type: 'VECTOR',
+ id: `${id}-vec`,
+ name: `${name}Vector`,
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ],
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ isAsset: true,
+ width: 20,
+ height: 20,
+ ...overrides,
+ } as unknown as SceneNode
+ }
+
+ function createTextChild3(
+ id: string,
+ name: string,
+ text: string,
+ ): SceneNode {
+ return {
+ type: 'TEXT',
+ id,
+ name,
+ visible: true,
+ characters: text,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ width: 50,
+ height: 20,
+ fontSize: 16,
+ fontName: { family: 'Pretendard', style: 'Bold' },
+ fontWeight: 600,
+ textAlignHorizontal: 'LEFT',
+ textAlignVertical: 'CENTER',
+ letterSpacing: { value: -2, unit: 'PERCENT' },
+ lineHeight: { unit: 'AUTO' },
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ textStyleId: '',
+ getStyledTextSegments: () => [
+ {
+ characters: text,
+ start: 0,
+ end: text.length,
+ fontSize: 16,
+ fontName: { family: 'Pretendard', style: 'Bold' },
+ fontWeight: 600,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ textDecoration: 'NONE',
+ textCase: 'ORIGINAL',
+ lineHeight: { unit: 'AUTO' },
+ letterSpacing: { value: -2, unit: 'PERCENT' },
+ textStyleId: '',
+ fillStyleId: '',
+ listOptions: { type: 'NONE' },
+ indentation: 0,
+ hyperlink: null,
+ },
+ ],
+ } as unknown as SceneNode
+ }
+
+ const sizes = ['lg', 'tag']
+ const varients = ['primary', 'white']
+ const allChildren: ComponentNode[] = []
+
+ for (const size of sizes) {
+ for (const varient of varients) {
+ const hasIcons = size !== 'tag'
+ const id = `cache-${size}-${varient}`
+ const children: SceneNode[] = []
+ if (hasIcons) {
+ children.push(
+ createInstanceChild3(`icon-${id}`, 'MypageIcon', {
+ componentPropertyReferences: {
+ visible: 'leftIcon#70:100',
+ },
+ }),
+ )
+ }
+ children.push(createTextChild3(`text-${id}`, 'Label', `btn${size}`))
+ allChildren.push(
+ createComponentNode(
+ `size=${size}, varient=${varient}`,
+ { size, varient },
+ {
+ id,
+ children: children as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 10,
+ paddingLeft: size === 'tag' ? 10 : 24,
+ paddingRight: size === 'tag' ? 10 : 24,
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ ),
+ )
+ }
+ }
+
+ const componentSet = createComponentSetNode(
+ 'CacheButton',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'lg',
+ variantOptions: sizes,
+ },
+ varient: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: varients,
+ },
+ 'leftIcon#70:100': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ },
+ allChildren,
+ )
+
+ // Set parent references
+ for (const child of allChildren) {
+ ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet
+ if ('children' in child && child.children) {
+ for (const grandchild of child.children) {
+ ;(grandchild as unknown as { parent: SceneNode }).parent = child
+ }
+ }
+ }
+
+ // Step 1: Run codegen.run() first — this populates globalBuildTreeCache
+ // (simulates what happens in code-impl.ts before generateVariantResponsiveComponents)
+ const codegen = new Codegen(componentSet as unknown as SceneNode)
+ await codegen.run()
+
+ // Step 2: Reset cache (this is the fix in code-impl.ts)
+ resetGlobalBuildTreeCache()
+
+ // Step 3: Now generate responsive components
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'CacheButton',
+ )
+
+ expect(codes.length).toBe(1)
+ const [, generatedCode] = codes[0]
+
+ // Should have leftIcon as a boolean prop
+ expect(generatedCode).toContain('leftIcon?: boolean')
+
+ // MypageIcon should have leftIcon condition — THIS WOULD FAIL without the cache reset
+ expect(generatedCode).toMatch(/leftIcon\s*&&/)
+ })
+
+ test('generates BOOLEAN conditions with effect variants (real Button scenario)', async () => {
+ // Mirrors the REAL Figma Button with effect variants:
+ // - size: lg, md, sm, tag (VARIANT)
+ // - varient: primary, white, ghost (VARIANT)
+ // - effect: default, hover (VARIANT) — becomes pseudo-selectors
+ // - leftIcon: boolean (BOOLEAN) — controls MypageIcon visibility
+ //
+ // The effect variant is filtered to only keep 'default' children.
+ // This ensures the BOOLEAN condition still propagates through the
+ // effect-filtering + multi-variant merge pipeline.
+
+ function createInstanceChild2(
+ id: string,
+ name: string,
+ overrides: Record = {},
+ ): SceneNode {
+ return {
+ type: 'INSTANCE',
+ id,
+ name,
+ visible: true,
+ children: [
+ {
+ type: 'VECTOR',
+ id: `${id}-vec`,
+ name: `${name}Vector`,
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ],
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ isAsset: true,
+ width: 20,
+ height: 20,
+ ...overrides,
+ } as unknown as SceneNode
+ }
+
+ function createTextChild2(
+ id: string,
+ name: string,
+ text: string,
+ ): SceneNode {
+ return {
+ type: 'TEXT',
+ id,
+ name,
+ visible: true,
+ characters: text,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ width: 50,
+ height: 20,
+ fontSize: 16,
+ fontName: { family: 'Pretendard', style: 'Bold' },
+ fontWeight: 600,
+ textAlignHorizontal: 'LEFT',
+ textAlignVertical: 'CENTER',
+ letterSpacing: { value: -2, unit: 'PERCENT' },
+ lineHeight: { unit: 'AUTO' },
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ textStyleId: '',
+ getStyledTextSegments: () => [
+ {
+ characters: text,
+ start: 0,
+ end: text.length,
+ fontSize: 16,
+ fontName: { family: 'Pretendard', style: 'Bold' },
+ fontWeight: 600,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ textDecoration: 'NONE',
+ textCase: 'ORIGINAL',
+ lineHeight: { unit: 'AUTO' },
+ letterSpacing: { value: -2, unit: 'PERCENT' },
+ textStyleId: '',
+ fillStyleId: '',
+ listOptions: { type: 'NONE' },
+ indentation: 0,
+ hyperlink: null,
+ },
+ ],
+ } as unknown as SceneNode
+ }
+
+ const sizes = ['lg', 'md', 'sm', 'tag']
+ const varients = ['primary', 'white', 'ghost']
+ const effects = ['default', 'hover']
+ const allChildren: ComponentNode[] = []
+
+ for (const size of sizes) {
+ for (const varient of varients) {
+ for (const effect of effects) {
+ const hasIcons = size !== 'tag'
+ const id = `${size}-${varient}-${effect}`
+ const children: SceneNode[] = []
+ if (hasIcons) {
+ children.push(
+ createInstanceChild2(`icon-${id}`, 'MypageIcon', {
+ componentPropertyReferences: {
+ visible: 'leftIcon#70:100',
+ },
+ }),
+ )
+ }
+ children.push(createTextChild2(`text-${id}`, 'Label', `btn${size}`))
+ if (hasIcons) {
+ children.push(
+ createInstanceChild2(`arrow-${id}`, 'Arrow', {
+ componentPropertyReferences: {
+ visible: 'rightIcon#71:101',
+ },
+ }),
+ )
+ }
+ const bgColor =
+ effect === 'hover'
+ ? { r: 0.3, g: 0.5, b: 0.9 }
+ : { r: 0.2, g: 0.4, b: 0.8 }
+ allChildren.push(
+ createComponentNode(
+ `size=${size}, varient=${varient}, effect=${effect}`,
+ { size, varient, effect },
+ {
+ id,
+ children: children as unknown as readonly SceneNode[],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 10,
+ paddingLeft: size === 'tag' ? 10 : 24,
+ paddingRight: size === 'tag' ? 10 : 24,
+ paddingTop: 10,
+ paddingBottom: 10,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: bgColor,
+ opacity: 1,
+ } as unknown as Paint,
+ ],
+ },
+ ),
+ )
+ }
+ }
+ }
+
+ const componentSet = createComponentSetNode(
+ 'Button',
+ {
+ size: {
+ type: 'VARIANT',
+ defaultValue: 'lg',
+ variantOptions: sizes,
+ },
+ varient: {
+ type: 'VARIANT',
+ defaultValue: 'primary',
+ variantOptions: varients,
+ },
+ effect: {
+ type: 'VARIANT',
+ defaultValue: 'default',
+ variantOptions: effects,
+ },
+ 'leftIcon#70:100': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ 'rightIcon#71:101': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ },
+ allChildren,
+ )
+
+ // Set parent references
+ for (const child of allChildren) {
+ ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet
+ if ('children' in child && child.children) {
+ for (const grandchild of child.children) {
+ ;(grandchild as unknown as { parent: SceneNode }).parent = child
+ }
+ }
+ }
+
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Button',
+ )
+
+ expect(codes.length).toBe(1)
+ const [, generatedCode] = codes[0]
+
+ // Should have leftIcon and rightIcon as boolean props
+ expect(generatedCode).toContain('leftIcon?: boolean')
+ expect(generatedCode).toContain('rightIcon?: boolean')
+
+ // MypageIcon should have leftIcon condition
+ expect(generatedCode).toMatch(/leftIcon\s*&&/)
+ // Arrow should have rightIcon condition
+ expect(generatedCode).toMatch(/rightIcon\s*&&/)
+ })
})
diff --git a/src/codegen/props/__tests__/selector.test.ts b/src/codegen/props/__tests__/selector.test.ts
index 49fc83b..dcafd9d 100644
--- a/src/codegen/props/__tests__/selector.test.ts
+++ b/src/codegen/props/__tests__/selector.test.ts
@@ -146,6 +146,41 @@ describe('getSelectorProps', () => {
expect(result?.variants.showIcon).toBe('boolean')
})
+ test('includes TEXT properties as string in variants', async () => {
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'size=Default',
+ children: [],
+ visible: true,
+ reactions: [],
+ variantProperties: { size: 'Default' },
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'ButtonWithLabel',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ size: {
+ type: 'VARIANT',
+ variantOptions: ['Default', 'Small'],
+ },
+ 'label#80:456': {
+ type: 'TEXT',
+ defaultValue: 'Click me',
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ const result = await getSelectorProps(node)
+
+ expect(result).toBeDefined()
+ expect(result?.variants.size).toBe("'Default' | 'Small'")
+ expect(result?.variants.label).toBe('string')
+ })
+
test('includes INSTANCE_SWAP properties as React.ReactNode in variants', async () => {
const defaultVariant = {
type: 'COMPONENT',
diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts
index 149df08..830acec 100644
--- a/src/codegen/props/selector.ts
+++ b/src/codegen/props/selector.ts
@@ -163,6 +163,8 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{
result.variants[sanitizedName] = 'React.ReactNode'
} else if (definition.type === 'BOOLEAN') {
result.variants[sanitizedName] = 'boolean'
+ } else if (definition.type === 'TEXT') {
+ result.variants[sanitizedName] = 'string'
}
}
diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts
index 7d53422..6f3ed30 100644
--- a/src/codegen/responsive/ResponsiveCodegen.ts
+++ b/src/codegen/responsive/ResponsiveCodegen.ts
@@ -35,16 +35,118 @@ function firstMapValue(map: Map): V {
throw new Error('empty map')
}
-function firstMapKey(map: Map): K {
- for (const k of map.keys()) return k
- throw new Error('empty map')
-}
-
function firstMapEntry(map: Map): [K, V] {
for (const entry of map.entries()) return entry
throw new Error('empty map')
}
+/**
+ * Build a stable merged order of child names across multiple variants/breakpoints.
+ * Uses topological sort on a DAG of ordering constraints from all variants,
+ * with average-position tie-breaking for deterministic output.
+ *
+ * Example: variant A has [Icon, TextA, Arrow], variant B has [Icon, TextB, Arrow]
+ * → edges: Icon→TextA, TextA→Arrow, Icon→TextB, TextB→Arrow
+ * → topo sort: [Icon, TextA, TextB, Arrow] (Arrow stays last)
+ */
+function mergeChildNameOrder(
+ childrenMaps: Map>,
+): string[] {
+ // Collect distinct child name sequences from each variant
+ const sequences: string[][] = []
+ for (const childMap of childrenMaps.values()) {
+ const seq: string[] = []
+ for (const name of childMap.keys()) {
+ seq.push(name)
+ }
+ sequences.push(seq)
+ }
+
+ // Collect all unique names
+ const allNames = new Set()
+ for (const seq of sequences) {
+ for (const name of seq) {
+ allNames.add(name)
+ }
+ }
+
+ if (allNames.size === 0) return []
+ if (allNames.size === 1) return [...allNames]
+
+ // Build DAG: for each variant, add edge from consecutive distinct names
+ const edges = new Map>()
+ const inDegree = new Map()
+ for (const name of allNames) {
+ edges.set(name, new Set())
+ inDegree.set(name, 0)
+ }
+
+ for (const seq of sequences) {
+ for (let i = 0; i < seq.length - 1; i++) {
+ const from = seq[i]
+ const to = seq[i + 1]
+ const fromEdges = edges.get(from)
+ if (fromEdges && !fromEdges.has(to)) {
+ fromEdges.add(to)
+ inDegree.set(to, (inDegree.get(to) || 0) + 1)
+ }
+ }
+ }
+
+ // Compute average normalized position for tie-breaking
+ const avgPosition = new Map()
+ for (const name of allNames) {
+ let totalPos = 0
+ let count = 0
+ for (const seq of sequences) {
+ const idx = seq.indexOf(name)
+ if (idx >= 0) {
+ // Normalize to 0..1 range
+ totalPos += seq.length > 1 ? idx / (seq.length - 1) : 0.5
+ count++
+ }
+ }
+ avgPosition.set(name, count > 0 ? totalPos / count : 0.5)
+ }
+
+ // Kahn's algorithm with priority-based tie-breaking
+ const queue: string[] = []
+ for (const [name, deg] of inDegree) {
+ if (deg === 0) queue.push(name)
+ }
+ // Sort initial queue by average position (stable)
+ queue.sort((a, b) => (avgPosition.get(a) || 0) - (avgPosition.get(b) || 0))
+
+ const result: string[] = []
+ while (queue.length > 0) {
+ const node = queue.shift()
+ if (!node) break
+ result.push(node)
+ for (const neighbor of edges.get(node) || []) {
+ const newDeg = (inDegree.get(neighbor) || 1) - 1
+ inDegree.set(neighbor, newDeg)
+ if (newDeg === 0) {
+ queue.push(neighbor)
+ // Re-sort to maintain priority order
+ queue.sort(
+ (a, b) => (avgPosition.get(a) || 0) - (avgPosition.get(b) || 0),
+ )
+ }
+ }
+ }
+
+ // Cycle fallback: append any remaining nodes (shouldn't happen with consistent data)
+ if (result.length < allNames.size) {
+ for (const name of allNames) {
+ if (!result.includes(name)) {
+ result.push(name)
+ }
+ }
+ }
+
+ return result
+}
+
/**
* Generate responsive code by merging children inside a Section.
* Uses Codegen to build NodeTree for each breakpoint, then merges them.
@@ -212,7 +314,6 @@ export class ResponsiveCodegen {
// Merge children by name
const childrenCodes: string[] = []
- const processedChildNames = new Set()
// Convert all trees' children to maps
const childrenMaps = new Map>()
@@ -220,27 +321,8 @@ export class ResponsiveCodegen {
childrenMaps.set(bp, this.treeChildrenToMap(tree))
}
- // Get all child names in order (first tree's order, then others)
- const firstBreakpoint = firstMapKey(treesByBreakpoint)
- const firstChildrenMap = childrenMaps.get(firstBreakpoint)
- const allChildNames: string[] = []
-
- if (firstChildrenMap) {
- for (const name of firstChildrenMap.keys()) {
- allChildNames.push(name)
- processedChildNames.add(name)
- }
- }
-
- // Add children that exist only in other breakpoints
- for (const childMap of childrenMaps.values()) {
- for (const name of childMap.keys()) {
- if (!processedChildNames.has(name)) {
- allChildNames.push(name)
- processedChildNames.add(name)
- }
- }
- }
+ // Get all child names in stable merged order across all breakpoints
+ const allChildNames = mergeChildNameOrder(childrenMaps)
for (const childName of allChildNames) {
// Find the maximum number of children with this name across all breakpoints
@@ -332,10 +414,20 @@ export class ResponsiveCodegen {
const variants: Record = {}
for (const name in componentSet.componentPropertyDefinitions) {
const definition = componentSet.componentPropertyDefinitions[name]
- if (name.toLowerCase() !== 'viewport' && definition.type === 'VARIANT') {
+ const lowerName = name.toLowerCase()
+ if (lowerName !== 'viewport' && lowerName !== 'effect') {
const sanitizedName = sanitizePropertyName(name)
- variants[sanitizedName] =
- definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') || ''
+ if (definition.type === 'VARIANT') {
+ variants[sanitizedName] =
+ definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') ||
+ ''
+ } else if (definition.type === 'INSTANCE_SWAP') {
+ variants[sanitizedName] = 'React.ReactNode'
+ } else if (definition.type === 'BOOLEAN') {
+ variants[sanitizedName] = 'boolean'
+ } else if (definition.type === 'TEXT') {
+ variants[sanitizedName] = 'string'
+ }
}
}
@@ -480,6 +572,15 @@ export class ResponsiveCodegen {
definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') ||
''
}
+ } else if (definition.type === 'INSTANCE_SWAP') {
+ const sanitizedName = sanitizePropertyName(name)
+ variants[sanitizedName] = 'React.ReactNode'
+ } else if (definition.type === 'BOOLEAN') {
+ const sanitizedName = sanitizePropertyName(name)
+ variants[sanitizedName] = 'boolean'
+ } else if (definition.type === 'TEXT') {
+ const sanitizedName = sanitizePropertyName(name)
+ variants[sanitizedName] = 'string'
}
}
@@ -673,9 +774,22 @@ export class ResponsiveCodegen {
// Render the tree to JSX
const code = Codegen.renderTree(tree, 0)
- // No variant props needed since effect is handled via pseudo-selectors
+ // Collect BOOLEAN and INSTANCE_SWAP props for the interface
+ // (effect is handled via pseudo-selectors, VARIANT keys don't exist in effect-only path)
+ const variants: Record = {}
+ for (const name in componentSet.componentPropertyDefinitions) {
+ const definition = componentSet.componentPropertyDefinitions[name]
+ if (definition.type === 'INSTANCE_SWAP') {
+ variants[sanitizePropertyName(name)] = 'React.ReactNode'
+ } else if (definition.type === 'BOOLEAN') {
+ variants[sanitizePropertyName(name)] = 'boolean'
+ } else if (definition.type === 'TEXT') {
+ variants[sanitizePropertyName(name)] = 'string'
+ }
+ }
+
const result: Array = [
- [componentName, renderComponent(componentName, code, {})],
+ [componentName, renderComponent(componentName, code, variants)],
]
return result
}
@@ -693,8 +807,160 @@ export class ResponsiveCodegen {
return []
}
+ // Check if componentSet has effect variant (pseudo-selector)
+ let hasEffect = false
+ for (const key in componentSet.componentPropertyDefinitions) {
+ if (key.toLowerCase() === 'effect') {
+ hasEffect = true
+ break
+ }
+ }
+
+ // Map from original name to sanitized name
+ const variantKeyToSanitized: Record = {}
+ for (const key of variantKeys) {
+ variantKeyToSanitized[key] = sanitizePropertyName(key)
+ }
+ const sanitizedVariantKeys = variantKeys.map(
+ (key) => variantKeyToSanitized[key],
+ )
+
+ // Single variant key: use simpler single-dimension merge
+ if (variantKeys.length === 1) {
+ return ResponsiveCodegen.generateSingleVariantComponents(
+ componentSet,
+ componentName,
+ variantKeys[0],
+ sanitizedVariantKeys[0],
+ variants,
+ hasEffect,
+ )
+ }
+
+ // Multiple variant keys: build trees for ALL combinations, use multi-dimensional merge
+ // Build composite key for each component (e.g., "size=lg|varient=primary")
+ const buildCompositeKey = (
+ variantProps: Record,
+ ): string => {
+ return variantKeys
+ .map((key) => {
+ const sanitizedKey = variantKeyToSanitized[key]
+ return `${sanitizedKey}=${variantProps[key] || '__default__'}`
+ })
+ .join('|')
+ }
+
+ // Reverse mapping from sanitized to original names (for getSelectorPropsForGroup)
+ const sanitizedToOriginal: Record = {}
+ for (const [original, sanitized] of Object.entries(variantKeyToSanitized)) {
+ sanitizedToOriginal[sanitized] = original
+ }
+
+ const parseCompositeKeyToOriginal = (
+ compositeKey: string,
+ ): Record => {
+ const result: Record = {}
+ for (const part of compositeKey.split('|')) {
+ const [sanitizedKey, value] = part.split('=')
+ const originalKey = sanitizedToOriginal[sanitizedKey]
+ if (originalKey) {
+ result[originalKey] = value
+ }
+ }
+ return result
+ }
+
+ // Group components by composite variant key (all variant values combined)
+ const componentsByComposite = new Map()
+ for (const child of componentSet.children) {
+ if (child.type !== 'COMPONENT') continue
+
+ const component = child as ComponentNode
+ const variantProps = component.variantProperties || {}
+
+ // Skip effect variants (they become pseudo-selectors)
+ if (hasEffect) {
+ const effectValue =
+ variantProps[
+ Object.keys(componentSet.componentPropertyDefinitions).find(
+ (k) => k.toLowerCase() === 'effect',
+ ) || ''
+ ]
+ if (effectValue && effectValue !== 'default') continue
+ }
+
+ const compositeKey = buildCompositeKey(variantProps)
+ if (!componentsByComposite.has(compositeKey)) {
+ componentsByComposite.set(compositeKey, component)
+ }
+ }
+
+ // Build trees for each combination
+ const treesByComposite = new Map()
+ for (const [compositeKey, component] of componentsByComposite) {
+ const variantFilter = parseCompositeKeyToOriginal(compositeKey)
+ let t = perfStart()
+ const selectorProps = hasEffect
+ ? await getSelectorPropsForGroup(componentSet, variantFilter)
+ : null
+ perfEnd('getSelectorPropsForGroup(nonViewport)', t)
+
+ t = perfStart()
+ const codegen = new Codegen(component)
+ const tree = await codegen.getTree()
+ perfEnd('Codegen.getTree(nonViewportVariant)', t)
+
+ // Use the component tree from addComponentTree if available — it includes
+ // ALL children (even invisible BOOLEAN-controlled ones) with condition fields
+ // and INSTANCE_SWAP slot placeholders, which buildTree() skips.
+ const componentTree = codegen.getComponentTree()
+ if (componentTree) {
+ tree.children = componentTree.tree.children
+ }
+
+ if (selectorProps && Object.keys(selectorProps).length > 0) {
+ tree.props = Object.assign({}, tree.props, selectorProps)
+ }
+ treesByComposite.set(compositeKey, tree)
+ }
+
+ // Use multi-dimensional merge (same as viewport+variant path but without viewport)
+ // Wrap each tree in a single-breakpoint map so generateMultiVariantMergedCode works
+ const treesByCompositeAndBreakpoint = new Map<
+ string,
+ Map
+ >()
+ for (const [compositeKey, tree] of treesByComposite) {
+ const singleBreakpointMap = new Map()
+ singleBreakpointMap.set('pc', tree)
+ treesByCompositeAndBreakpoint.set(compositeKey, singleBreakpointMap)
+ }
+
+ const responsiveCodegen = new ResponsiveCodegen(null)
+ const mergedCode = responsiveCodegen.generateMultiVariantMergedCode(
+ sanitizedVariantKeys,
+ treesByCompositeAndBreakpoint,
+ 0,
+ )
+
+ const result: Array = [
+ [componentName, renderComponent(componentName, mergedCode, variants)],
+ ]
+ return result
+ }
+
+ /**
+ * Generate component code for single variant key (original simple path).
+ */
+ private static async generateSingleVariantComponents(
+ componentSet: ComponentSetNode,
+ componentName: string,
+ variantKey: string,
+ sanitizedVariantKey: string,
+ variants: Record,
+ hasEffect: boolean,
+ ): Promise> {
// Group components by variant value
- const primaryVariantKey = variantKeys[0]
const componentsByVariant = new Map()
for (const child of componentSet.children) {
@@ -702,28 +968,18 @@ export class ResponsiveCodegen {
const component = child as ComponentNode
const variantProps = component.variantProperties || {}
- const variantValue = variantProps[primaryVariantKey] || '__default__'
+ const variantValue = variantProps[variantKey] || '__default__'
if (!componentsByVariant.has(variantValue)) {
componentsByVariant.set(variantValue, component)
}
}
- // Check if componentSet has effect variant (pseudo-selector)
- let hasEffect = false
- for (const key in componentSet.componentPropertyDefinitions) {
- if (key.toLowerCase() === 'effect') {
- hasEffect = true
- break
- }
- }
-
- // Build trees for each variant — all independent, run in parallel.
+ // Build trees for each variant
const treesByVariant = new Map()
for (const [variantValue, component] of componentsByVariant) {
- // Get pseudo-selector props for this specific variant group
const variantFilter: Record = {
- [primaryVariantKey]: variantValue,
+ [variantKey]: variantValue,
}
let t = perfStart()
const selectorProps = hasEffect
@@ -735,7 +991,15 @@ export class ResponsiveCodegen {
const codegen = new Codegen(component)
const tree = await codegen.getTree()
perfEnd('Codegen.getTree(nonViewportVariant)', t)
- // Add pseudo-selector props to tree — create NEW props to avoid mutating cached tree
+
+ // Use the component tree from addComponentTree if available — it includes
+ // ALL children (even invisible BOOLEAN-controlled ones) with condition fields
+ // and INSTANCE_SWAP slot placeholders, which buildTree() skips.
+ const componentTree = codegen.getComponentTree()
+ if (componentTree) {
+ tree.children = componentTree.tree.children
+ }
+
if (selectorProps && Object.keys(selectorProps).length > 0) {
tree.props = Object.assign({}, tree.props, selectorProps)
}
@@ -744,10 +1008,8 @@ export class ResponsiveCodegen {
// Generate merged code with variant conditionals
const responsiveCodegen = new ResponsiveCodegen(null)
- // Use sanitized variant key for code generation (e.g., "속성 1" -> "property1")
- const sanitizedPrimaryVariantKey = sanitizePropertyName(primaryVariantKey)
const mergedCode = responsiveCodegen.generateVariantOnlyMergedCode(
- sanitizedPrimaryVariantKey,
+ sanitizedVariantKey,
treesByVariant,
0,
)
@@ -811,26 +1073,8 @@ export class ResponsiveCodegen {
childrenMaps.set(bp, this.treeChildrenToMap(tree))
}
- const processedChildNames = new Set()
- const allChildNames: string[] = []
- const firstBreakpoint = firstMapKey(treesByBreakpoint)
- 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)
- }
- }
- }
+ // Get all child names in stable merged order across all breakpoints
+ const allChildNames = mergeChildNameOrder(childrenMaps)
const mergedChildren: NodeTree[] = []
@@ -912,11 +1156,15 @@ export class ResponsiveCodegen {
// Handle TEXT nodes
if (firstTree.textChildren && firstTree.textChildren.length > 0) {
+ const mergedTextChildren = this.mergeTextChildrenAcrossVariants(
+ variantKey,
+ treesByVariant,
+ )
return renderNode(
firstTree.component,
mergedProps,
depth,
- firstTree.textChildren,
+ mergedTextChildren,
)
}
@@ -927,26 +1175,8 @@ export class ResponsiveCodegen {
childrenMaps.set(variant, this.treeChildrenToMap(tree))
}
- const processedChildNames = new Set()
- const allChildNames: string[] = []
- const firstVariant = firstMapKey(treesByVariant)
- 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)
- }
- }
- }
+ // Get all child names in stable merged order across all variants
+ const allChildNames = mergeChildNameOrder(childrenMaps)
for (const childName of allChildNames) {
let maxChildCount = 0
@@ -981,38 +1211,77 @@ export class ResponsiveCodegen {
childByVariant,
0,
)
+
+ // Check if all variants share the same BOOLEAN condition
+ const firstChild = firstMapValue(childByVariant)
+ const condition = firstChild.condition
+ if (condition) {
+ let allSameCondition = true
+ for (const child of childByVariant.values()) {
+ if (child.condition !== condition) {
+ allSameCondition = false
+ break
+ }
+ }
+ if (allSameCondition) {
+ if (childCode.includes('\n')) {
+ childrenCodes.push(
+ `{${condition} && (\n${paddingLeftMultiline(childCode, 1)}\n)}`,
+ )
+ } else {
+ childrenCodes.push(`{${condition} && ${childCode}}`)
+ }
+ continue
+ }
+ }
+
+ // Handle INSTANCE_SWAP slot placeholders
+ if (firstChild.isSlot) {
+ childrenCodes.push(`{${firstChild.component}}`)
+ continue
+ }
+
childrenCodes.push(childCode)
} else {
// Child exists only in some variants - use conditional rendering
const presentVariantsList = [...presentVariants]
+ // Check if present children share a common BOOLEAN condition
+ const sharedCondition = this.getSharedCondition(childByVariant)
+
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)
+ const variantCondition = `${variantKey} === "${onlyVariant}"`
+ const fullCondition = sharedCondition
+ ? `${sharedCondition} && ${variantCondition}`
+ : variantCondition
const formattedChildCode = childCode.includes('\n')
? `(\n${paddingLeftMultiline(childCode, 1)}\n)`
: childCode
- childrenCodes.push(
- `{${variantKey} === "${onlyVariant}" && ${formattedChildCode}}`,
- )
+ childrenCodes.push(`{${fullCondition} && ${formattedChildCode}}`)
} else {
// Multiple (but not all) variants have this child
// Use conditional rendering with OR
const conditions = presentVariantsList
.map((v) => `${variantKey} === "${v}"`)
.join(' || ')
+ const variantCondition = `(${conditions})`
+ const fullCondition = sharedCondition
+ ? `${sharedCondition} && ${variantCondition}`
+ : variantCondition
const childCode = this.generateVariantOnlyMergedCode(
variantKey,
childByVariant,
0,
)
const formattedChildCode = childCode.includes('\n')
- ? `2(\n${paddingLeftMultiline(childCode, 1)}\n)`
+ ? `(\n${paddingLeftMultiline(childCode, 1)}\n)`
: childCode
- childrenCodes.push(`{(${conditions}) && ${formattedChildCode}}`)
+ childrenCodes.push(`{${fullCondition} && ${formattedChildCode}}`)
}
}
}
@@ -1094,11 +1363,15 @@ export class ResponsiveCodegen {
// Handle TEXT nodes
if (firstTree.textChildren && firstTree.textChildren.length > 0) {
+ const mergedTextChildren = this.mergeTextChildrenAcrossComposites(
+ variantKeys,
+ treesByComposite,
+ )
return renderNode(
firstTree.component,
mergedProps,
depth,
- firstTree.textChildren,
+ mergedTextChildren,
)
}
@@ -1111,27 +1384,8 @@ export class ResponsiveCodegen {
childrenMaps.set(compositeKey, this.treeChildrenToMap(tree))
}
- // Get all unique child names
- const processedChildNames = new Set()
- const allChildNames: string[] = []
- const firstComposite = firstMapKey(treesByComposite)
- const firstChildrenMap = childrenMaps.get(firstComposite)
-
- 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)
- }
- }
- }
+ // Get all unique child names in stable merged order across all composites
+ const allChildNames = mergeChildNameOrder(childrenMaps)
// Process each child
for (const childName of allChildNames) {
@@ -1165,13 +1419,150 @@ export class ResponsiveCodegen {
childByComposite,
0,
)
+
+ // Check if all composites share the same BOOLEAN condition
+ const firstChild = firstMapValue(childByComposite)
+ const condition = firstChild.condition
+ if (condition) {
+ // Check all composites have the same condition
+ let allSameCondition = true
+ for (const child of childByComposite.values()) {
+ if (child.condition !== condition) {
+ allSameCondition = false
+ break
+ }
+ }
+ if (allSameCondition) {
+ // Wrap with BOOLEAN conditional
+ if (childCode.includes('\n')) {
+ childrenCodes.push(
+ `{${condition} && (\n${paddingLeftMultiline(childCode, 1)}\n)}`,
+ )
+ } else {
+ childrenCodes.push(`{${condition} && ${childCode}}`)
+ }
+ continue
+ }
+ }
+
+ // Handle INSTANCE_SWAP slot placeholders
+ if (firstChild.isSlot) {
+ childrenCodes.push(`{${firstChild.component}}`)
+ continue
+ }
+
childrenCodes.push(childCode)
} else {
- // Child exists only in some variants - use first one for now
- // TODO: implement conditional rendering for partial children
- const firstChildTree = firstMapValue(childByComposite)
- const childCode = Codegen.renderTree(firstChildTree, 0)
- childrenCodes.push(childCode)
+ // Child exists only in some variants - conditional rendering
+ // Find which variant key(s) control this child's presence
+ const presentKeys = [...presentComposites]
+ const absentKeys = [...treesByComposite.keys()].filter(
+ (k) => !presentComposites.has(k),
+ )
+
+ // Parse all composite keys to find the controlling variant dimension
+ const parsedPresent = presentKeys.map((k) =>
+ this.parseCompositeKey(k),
+ )
+ const parsedAbsent = absentKeys.map((k) =>
+ this.parseCompositeKey(k),
+ )
+
+ // For each variant key, check if it fully explains presence/absence
+ let controllingKey: string | undefined
+ let presentValues: string[] = []
+
+ for (const vk of variantKeys) {
+ const presentVals = new Set(parsedPresent.map((p) => p[vk]))
+ const absentVals = new Set(parsedAbsent.map((p) => p[vk]))
+
+ // Check if there's no overlap — this key fully explains presence
+ let hasOverlap = false
+ for (const v of presentVals) {
+ if (absentVals.has(v)) {
+ hasOverlap = true
+ break
+ }
+ }
+
+ if (!hasOverlap) {
+ controllingKey = vk
+ presentValues = [...presentVals]
+ break
+ }
+ }
+
+ // Check if present children share a common BOOLEAN condition
+ const sharedCondition = this.getSharedCondition(childByComposite)
+
+ if (controllingKey && presentValues.length > 0) {
+ // Single controlling key — generate condition on that key
+ // Recursively merge the child across the present composites
+ const childCode =
+ childByComposite.size === 1
+ ? Codegen.renderTree(firstMapValue(childByComposite), 0)
+ : this.generateNestedVariantMergedCode(
+ variantKeys,
+ childByComposite,
+ 0,
+ )
+
+ if (presentValues.length === 1) {
+ // {key === "value" && }
+ const variantCondition = `${controllingKey} === "${presentValues[0]}"`
+ const fullCondition = sharedCondition
+ ? `${sharedCondition} && ${variantCondition}`
+ : variantCondition
+ const formattedChildCode = childCode.includes('\n')
+ ? `(\n${paddingLeftMultiline(childCode, 1)}\n)`
+ : childCode
+ childrenCodes.push(
+ `{${fullCondition} && ${formattedChildCode}}`,
+ )
+ } else {
+ // {(key === "a" || key === "b") && }
+ const conditions = presentValues
+ .map((v) => `${controllingKey} === "${v}"`)
+ .join(' || ')
+ const variantCondition = `(${conditions})`
+ const fullCondition = sharedCondition
+ ? `${sharedCondition} && ${variantCondition}`
+ : variantCondition
+ const formattedChildCode = childCode.includes('\n')
+ ? `(\n${paddingLeftMultiline(childCode, 1)}\n)`
+ : childCode
+ childrenCodes.push(
+ `{${fullCondition} && ${formattedChildCode}}`,
+ )
+ }
+ } else {
+ // Multiple keys control presence — build combined condition
+ // Collect unique composite value combinations that have this child
+ const conditionParts: string[] = []
+ for (const compositeKey of presentKeys) {
+ const parsed = this.parseCompositeKey(compositeKey)
+ const parts = variantKeys.map(
+ (vk) => `${vk} === "${parsed[vk]}"`,
+ )
+ conditionParts.push(`(${parts.join(' && ')})`)
+ }
+ const variantCondition = `(${conditionParts.join(' || ')})`
+ const fullCondition = sharedCondition
+ ? `${sharedCondition} && ${variantCondition}`
+ : variantCondition
+ const childCode =
+ childByComposite.size === 1
+ ? Codegen.renderTree(firstMapValue(childByComposite), 0)
+ : this.generateNestedVariantMergedCode(
+ variantKeys,
+ childByComposite,
+ 0,
+ )
+ const formattedChildCode = childCode.includes('\n')
+ ? `(\n${paddingLeftMultiline(childCode, 1)}\n)`
+ : childCode
+ childrenCodes.push(`{${fullCondition} && ${formattedChildCode}}`)
+ }
}
}
}
@@ -1180,6 +1571,40 @@ export class ResponsiveCodegen {
return renderNode(firstTree.component, mergedProps, depth, childrenCodes)
}
+ /**
+ * Parse a composite key like "size=lg|varient=primary" into { size: "lg", varient: "primary" }.
+ */
+ private parseCompositeKey(compositeKey: string): Record {
+ const parsed: Record = {}
+ for (const part of compositeKey.split('|')) {
+ const [key, value] = part.split('=')
+ parsed[key] = value
+ }
+ return parsed
+ }
+
+ /**
+ * Check if all children in a map share the same BOOLEAN condition.
+ * Returns the shared condition string, or undefined if not all share one.
+ */
+ private getSharedCondition(
+ childMap: Map,
+ ): string | undefined {
+ let sharedCondition: string | undefined
+ let first = true
+ for (const child of childMap.values()) {
+ if (first) {
+ sharedCondition = child.condition
+ first = false
+ continue
+ }
+ if (child.condition !== sharedCondition) {
+ return undefined
+ }
+ }
+ return sharedCondition
+ }
+
/**
* Merge props across composite variant keys.
* Creates nested variant conditionals for props that differ.
@@ -1225,24 +1650,28 @@ export class ResponsiveCodegen {
}
}
if (hasPseudoSelector) {
- result[propKey] = this.mergePropsAcrossComposites(
+ const merged = this.mergePropsAcrossComposites(
variantKeys,
pseudoPropsMap,
)
+ // Only include pseudo-selector if it has at least one prop after merging
+ if (Object.keys(merged).length > 0) {
+ result[propKey] = merged
+ }
}
continue
}
// Collect values for this prop across all composites
- // For composites that don't have this prop, use null
+ // For composites that don't have this prop (or value is undefined), use null
const valuesByComposite = new Map()
let hasValue = false
for (const [compositeKey, props] of propsMap) {
- if (propKey in props) {
+ if (propKey in props && props[propKey] !== undefined) {
valuesByComposite.set(compositeKey, props[propKey])
hasValue = true
} else {
- // Composite doesn't have this prop, use null
+ // Composite doesn't have this prop (or value is undefined), use null
valuesByComposite.set(compositeKey, null)
}
}
@@ -1455,6 +1884,127 @@ export class ResponsiveCodegen {
return 0
}
+ /**
+ * Merge text children across variant values (single variant key).
+ * If all variants have the same text, return it directly.
+ * If texts differ, create variant-mapped text: {{ lg: "buttonLg", md: "button" }[size]}
+ */
+ private mergeTextChildrenAcrossVariants(
+ variantKey: string,
+ treesByVariant: Map,
+ ): string[] {
+ // Collect joined text from each variant
+ const textByVariant = new Map()
+ for (const [variant, tree] of treesByVariant) {
+ if (tree.textChildren && tree.textChildren.length > 0) {
+ textByVariant.set(variant, tree.textChildren.join(''))
+ }
+ }
+
+ // If no text, return first tree's text
+ if (textByVariant.size === 0) {
+ const firstTree = firstMapValue(treesByVariant)
+ return firstTree.textChildren || []
+ }
+
+ // Check if all texts are the same
+ let allSame = true
+ let firstText: string | undefined
+ for (const text of textByVariant.values()) {
+ if (firstText === undefined) {
+ firstText = text
+ continue
+ }
+ if (text !== firstText) {
+ allSame = false
+ break
+ }
+ }
+
+ if (allSame) {
+ return firstMapValue(treesByVariant).textChildren || []
+ }
+
+ // Texts differ — create variant-mapped text
+ const entries = [...textByVariant.entries()]
+ .map(([variant, text]) => ` ${variant}: "${text}"`)
+ .join(',\n')
+ return [`{{\n${entries}\n}[${variantKey}]}`]
+ }
+
+ /**
+ * Merge text children across composite variant keys (multiple variant dimensions).
+ * If all composites have the same text, return it directly.
+ * If texts differ, find the controlling variant key and create variant-mapped text.
+ */
+ private mergeTextChildrenAcrossComposites(
+ variantKeys: string[],
+ treesByComposite: Map,
+ ): string[] {
+ // Collect joined text from each composite
+ const textByComposite = new Map()
+ for (const [compositeKey, tree] of treesByComposite) {
+ if (tree.textChildren && tree.textChildren.length > 0) {
+ textByComposite.set(compositeKey, tree.textChildren.join(''))
+ }
+ }
+
+ // If no text, return first tree's text
+ if (textByComposite.size === 0) {
+ const firstTree = firstMapValue(treesByComposite)
+ return firstTree.textChildren || []
+ }
+
+ // Check if all texts are the same
+ let allSame = true
+ let firstText: string | undefined
+ for (const text of textByComposite.values()) {
+ if (firstText === undefined) {
+ firstText = text
+ continue
+ }
+ if (text !== firstText) {
+ allSame = false
+ break
+ }
+ }
+
+ if (allSame) {
+ return firstMapValue(treesByComposite).textChildren || []
+ }
+
+ // Texts differ — find which variant key controls the text difference
+ // Group text values by each variant key to find the simplest mapping
+ for (const vk of variantKeys) {
+ const textByVariantValue = new Map()
+ let isConsistent = true
+
+ for (const [compositeKey, text] of textByComposite) {
+ const parsed = this.parseCompositeKey(compositeKey)
+ const variantValue = parsed[vk]
+ const existing = textByVariantValue.get(variantValue)
+
+ if (existing !== undefined && existing !== text) {
+ // Same variant value maps to different texts — this key doesn't fully control
+ isConsistent = false
+ break
+ }
+ textByVariantValue.set(variantValue, text)
+ }
+
+ if (isConsistent) {
+ // This variant key fully controls text — create mapping on this key
+ const entries = [...textByVariantValue.entries()]
+ .map(([variant, text]) => ` ${variant}: "${text}"`)
+ .join(',\n')
+ return [`{{\n${entries}\n}[${vk}]}`]
+ }
+ }
+
+ // No single key controls — use first tree's text as fallback
+ return firstMapValue(treesByComposite).textChildren || []
+ }
+
/**
* Merge text children across breakpoints.
* Compares text content and handles \n differences with responsive
display.
diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
index 1783fb1..5d73e03 100644
--- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
+++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
@@ -46,6 +46,7 @@ const mockRenderTree = mock((tree: NodeTree, depth: number) =>
const MockCodegen = class {
getTree = mockGetTree
+ getComponentTree = mock(() => undefined)
static renderTree = mockRenderTree
}
@@ -570,6 +571,325 @@ describe('ResponsiveCodegen', () => {
expect(result).toContain('&&')
expect(result).toMatchSnapshot()
})
+
+ it('prepends BOOLEAN condition on partial children (single variant, single value)', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // MypageIcon exists only in "lg" variant with condition: 'leftIcon'
+ const iconChild: NodeTree = {
+ component: 'Box',
+ props: { maskImage: 'url(/icons/MypageIcon.svg)' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'MypageIcon',
+ condition: 'leftIcon',
+ }
+
+ const treesByVariant = new Map([
+ [
+ 'lg',
+ {
+ component: 'Flex',
+ props: {},
+ children: [iconChild],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'tag',
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'size',
+ treesByVariant,
+ 0,
+ )
+
+ // Should prepend BOOLEAN condition before variant condition
+ expect(result).toContain('leftIcon && size === "lg"')
+ })
+
+ it('prepends BOOLEAN condition on partial children (single variant, multiple values)', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // MypageIcon exists in lg, md, sm but NOT tag — all with condition 'leftIcon'
+ const iconChild: NodeTree = {
+ component: 'Box',
+ props: { maskImage: 'url(/icons/MypageIcon.svg)' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'MypageIcon',
+ condition: 'leftIcon',
+ }
+
+ const treesByVariant = new Map([
+ [
+ 'lg',
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'md',
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'sm',
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'tag',
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'size',
+ treesByVariant,
+ 0,
+ )
+
+ // Should prepend BOOLEAN condition before OR variant condition
+ expect(result).toContain(
+ 'leftIcon && (size === "lg" || size === "md" || size === "sm")',
+ )
+ })
+
+ it('prepends BOOLEAN condition on partial children (multi-variant, single controlling key)', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // MypageIcon exists in lg|primary and lg|white but NOT tag|primary and tag|white
+ // Controlling key = size (lg vs tag), shared condition = 'leftIcon'
+ const iconChild: NodeTree = {
+ component: 'Box',
+ props: { maskImage: 'url(/icons/MypageIcon.svg)' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'MypageIcon',
+ condition: 'leftIcon',
+ }
+
+ // Use generateMultiVariantMergedCode (public), which wraps each tree in a single-breakpoint map
+ const treesByCompositeAndBreakpoint = new Map<
+ string,
+ Map
+ >([
+ [
+ 'size=lg|varient=primary',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=lg|varient=white',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=tag|varient=primary',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=tag|varient=white',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ ])
+
+ const result = generator.generateMultiVariantMergedCode(
+ ['size', 'varient'],
+ treesByCompositeAndBreakpoint,
+ 0,
+ )
+
+ // Should prepend BOOLEAN condition before variant condition
+ expect(result).toContain('leftIcon && size === "lg"')
+ })
+
+ it('does not prepend condition when partial children have different conditions', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // Child in "lg" has condition 'leftIcon', child in "md" has condition 'rightIcon'
+ const treesByVariant = new Map([
+ [
+ 'lg',
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Box',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Icon',
+ condition: 'leftIcon',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'md',
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Box',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Icon',
+ condition: 'rightIcon',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'tag',
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'size',
+ treesByVariant,
+ 0,
+ )
+
+ // Should NOT contain leftIcon or rightIcon since conditions don't match
+ expect(result).not.toContain('leftIcon')
+ expect(result).not.toContain('rightIcon')
+ // Should still have variant condition
+ expect(result).toContain('size === "lg" || size === "md"')
+ })
+
+ it('does not prepend condition when partial children have no condition', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // Partial child without condition
+ const treesByVariant = new Map([
+ [
+ 'lg',
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Box',
+ props: { id: 'icon' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Icon',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'tag',
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'size',
+ treesByVariant,
+ 0,
+ )
+
+ // Should have variant condition but no boolean prefix
+ expect(result).toContain('size === "lg"')
+ expect(result).not.toContain('leftIcon')
+ })
})
describe('createNestedVariantProp optimization', () => {
@@ -919,33 +1239,187 @@ describe('ResponsiveCodegen', () => {
expect(result[0][1]).toContain('status')
})
- it('delegates to generateViewportResponsiveComponents when only viewport exists', async () => {
+ it('handles component set with multiple non-viewport variants', async () => {
const componentSet = {
type: 'COMPONENT_SET',
- name: 'ViewportOnly',
+ name: 'MultiVariant',
componentPropertyDefinitions: {
- viewport: {
+ size: {
type: 'VARIANT',
- variantOptions: ['mobile', 'desktop'],
+ variantOptions: ['sm', 'lg'],
+ },
+ color: {
+ type: 'VARIANT',
+ variantOptions: ['red', 'blue'],
},
},
children: [
{
type: 'COMPONENT',
- name: 'viewport=mobile',
- variantProperties: { viewport: 'mobile' },
+ name: 'size=sm, color=red',
+ variantProperties: { size: 'sm', color: 'red' },
children: [],
layoutMode: 'VERTICAL',
- width: 320,
- height: 100,
+ width: 100,
+ height: 50,
},
{
type: 'COMPONENT',
- name: 'viewport=desktop',
- variantProperties: { viewport: 'desktop' },
+ name: 'size=sm, color=blue',
+ variantProperties: { size: 'sm', color: 'blue' },
children: [],
- layoutMode: 'HORIZONTAL',
- width: 1200,
+ layoutMode: 'VERTICAL',
+ width: 100,
+ height: 50,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=lg, color=red',
+ variantProperties: { size: 'lg', color: 'red' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 200,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=lg, color=blue',
+ variantProperties: { size: 'lg', color: 'blue' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'MultiVariant',
+ )
+
+ expect(result.length).toBe(1)
+ expect(result[0][0]).toBe('MultiVariant')
+ // Should contain both variant keys
+ expect(result[0][1]).toContain('size')
+ expect(result[0][1]).toContain('color')
+ })
+
+ it('handles component set with multiple non-viewport variants and effect', async () => {
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'MultiVariantEffect',
+ componentPropertyDefinitions: {
+ size: {
+ type: 'VARIANT',
+ variantOptions: ['sm', 'lg'],
+ },
+ color: {
+ type: 'VARIANT',
+ variantOptions: ['red', 'blue'],
+ },
+ effect: {
+ type: 'VARIANT',
+ variantOptions: ['default', 'hover'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'size=sm, color=red, effect=default',
+ variantProperties: {
+ size: 'sm',
+ color: 'red',
+ effect: 'default',
+ },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 100,
+ height: 50,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=sm, color=red, effect=hover',
+ variantProperties: {
+ size: 'sm',
+ color: 'red',
+ effect: 'hover',
+ },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 100,
+ height: 50,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=lg, color=blue, effect=default',
+ variantProperties: {
+ size: 'lg',
+ color: 'blue',
+ effect: 'default',
+ },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 200,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'size=lg, color=blue, effect=hover',
+ variantProperties: {
+ size: 'lg',
+ color: 'blue',
+ effect: 'hover',
+ },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'MultiVariantEffect',
+ )
+
+ expect(result.length).toBe(1)
+ expect(result[0][0]).toBe('MultiVariantEffect')
+ // Should contain both variant keys (not effect)
+ expect(result[0][1]).toContain('size')
+ expect(result[0][1]).toContain('color')
+ })
+
+ 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,
},
],
@@ -2094,4 +2568,490 @@ describe('ResponsiveCodegen', () => {
expect(result).toContain('Box')
})
})
+
+ describe('generateNestedVariantMergedCode partial children with multiple presentValues', () => {
+ it('generates OR condition when child exists in multiple values of controlling key', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // Child "Badge" exists in variant=primary and variant=white, but NOT variant=outline
+ // Controlling key = varient, presentValues = ["primary", "white"] (length 2 → line 1498 branch)
+ const badgeChild: NodeTree = {
+ component: 'Box',
+ props: { bg: 'red' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Badge',
+ }
+
+ const treesByCompositeAndBreakpoint = new Map<
+ string,
+ Map
+ >([
+ [
+ 'size=lg|varient=primary',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...badgeChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=lg|varient=white',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...badgeChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=lg|varient=outline',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ ])
+
+ const result = generator.generateMultiVariantMergedCode(
+ ['size', 'varient'],
+ treesByCompositeAndBreakpoint,
+ 0,
+ )
+
+ // Should generate OR condition: (varient === "primary" || varient === "white")
+ expect(result).toContain('varient === "primary"')
+ expect(result).toContain('varient === "white"')
+ expect(result).toContain('||')
+ })
+ })
+
+ describe('generateNestedVariantMergedCode with no single controlling key', () => {
+ it('generates combined AND/OR condition when multiple keys control presence', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // Child "Icon" exists in size=lg|varient=primary and size=sm|varient=white
+ // but NOT in size=lg|varient=white and size=sm|varient=primary
+ // Neither key alone separates present from absent → line 1518 branch
+ const iconChild: NodeTree = {
+ component: 'Box',
+ props: { maskImage: 'url(/icon.svg)' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Icon',
+ }
+
+ const treesByCompositeAndBreakpoint = new Map<
+ string,
+ Map
+ >([
+ [
+ 'size=lg|varient=primary',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=lg|varient=white',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=sm|varient=primary',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ [
+ 'size=sm|varient=white',
+ new Map([
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [{ ...iconChild }],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ ],
+ ])
+
+ const result = generator.generateMultiVariantMergedCode(
+ ['size', 'varient'],
+ treesByCompositeAndBreakpoint,
+ 0,
+ )
+
+ // Should generate combined AND conditions joined with OR:
+ // (size === "lg" && varient === "primary") || (size === "sm" && varient === "white")
+ expect(result).toContain('size === "lg" && varient === "primary"')
+ expect(result).toContain('size === "sm" && varient === "white"')
+ expect(result).toContain('||')
+ })
+ })
+
+ describe('generateVariantOnlyMergedCode with differing textChildren', () => {
+ it('creates variant-mapped text when texts differ across variants', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const treesByVariant = new Map([
+ [
+ 'scroll',
+ {
+ component: 'Text',
+ props: { fontSize: '14px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['ScrollLabel'],
+ },
+ ],
+ [
+ 'default',
+ {
+ component: 'Text',
+ props: { fontSize: '16px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['DefaultLabel'],
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'status',
+ treesByVariant,
+ 0,
+ )
+
+ // Texts differ → variant-mapped text with entries like: scroll: "ScrollLabel"
+ expect(result).toContain('scroll: "ScrollLabel"')
+ expect(result).toContain('default: "DefaultLabel"')
+ expect(result).toContain('[status]')
+ })
+ })
+
+ describe('generateNestedVariantMergedCode with differing textChildren controlled by one key', () => {
+ it('creates variant-mapped text when texts differ and one key controls the text', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // size=Md → "Medium", size=Sm → "Small", variant doesn't matter
+ // size key consistently controls text
+ const treesByComposite = new Map([
+ [
+ 'size=Md|variant=primary',
+ {
+ component: 'Text',
+ props: { fontSize: '14px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['Medium'],
+ },
+ ],
+ [
+ 'size=Md|variant=white',
+ {
+ component: 'Text',
+ props: { fontSize: '14px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['Medium'],
+ },
+ ],
+ [
+ 'size=Sm|variant=primary',
+ {
+ component: 'Text',
+ props: { fontSize: '12px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['Small'],
+ },
+ ],
+ [
+ 'size=Sm|variant=white',
+ {
+ component: 'Text',
+ props: { fontSize: '12px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['Small'],
+ },
+ ],
+ ])
+
+ const result = (
+ generator as unknown as {
+ generateNestedVariantMergedCode: (
+ variantKeys: string[],
+ trees: Map,
+ depth: number,
+ ) => string
+ }
+ ).generateNestedVariantMergedCode(
+ ['size', 'variant'],
+ treesByComposite,
+ 0,
+ )
+
+ // size controls text: Md → "Medium", Sm → "Small"
+ expect(result).toContain('Md: "Medium"')
+ expect(result).toContain('Sm: "Small"')
+ expect(result).toContain('[size]')
+ })
+ })
+
+ describe('child ordering across variants', () => {
+ it('preserves relative child order when merging variants with different children', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // Simulates a Button component: lg has [LeftIcon, TextLg, RightIcon]
+ // md has [LeftIcon, TextMd, RightIcon], tag has [TextTag]
+ // RightIcon must appear AFTER all text nodes in merged output
+ const leftIcon: NodeTree = {
+ component: 'Box',
+ props: { maskImage: 'url(/icons/left.svg)' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'LeftIcon',
+ condition: 'leftIcon',
+ }
+ const rightIcon: NodeTree = {
+ component: 'Box',
+ props: { maskImage: 'url(/icons/right.svg)' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'RightIcon',
+ condition: 'rightIcon',
+ }
+
+ const treesByVariant = new Map([
+ [
+ 'lg',
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ { ...leftIcon },
+ {
+ component: 'Text',
+ props: { fontSize: '16px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'TextLg',
+ textChildren: ['buttonLg'],
+ },
+ { ...rightIcon },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'md',
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ { ...leftIcon },
+ {
+ component: 'Text',
+ props: { fontSize: '14px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'TextMd',
+ textChildren: ['button'],
+ },
+ { ...rightIcon },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'tag',
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Text',
+ props: { fontSize: '12px' },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'TextTag',
+ textChildren: ['Tag'],
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'size',
+ treesByVariant,
+ 0,
+ )
+
+ // Mock output format: render:Component:depth=N:props|children
+ // Use unique prop values to identify each child's position:
+ // LeftIcon → url(/icons/left.svg), RightIcon → url(/icons/right.svg)
+ // TextLg → buttonLg, TextMd → button, TextTag → Tag
+ const leftIconPos = result.indexOf('url(/icons/left.svg)')
+ const textLgPos = result.indexOf('buttonLg')
+ const textTagPos = result.indexOf('Tag')
+ const rightIconPos = result.indexOf('url(/icons/right.svg)')
+
+ // LeftIcon should appear
+ expect(leftIconPos).toBeGreaterThanOrEqual(0)
+ // RightIcon should appear after LeftIcon and all texts
+ expect(rightIconPos).toBeGreaterThan(leftIconPos)
+ expect(rightIconPos).toBeGreaterThan(textLgPos)
+ // Tag text should come before RightIcon
+ if (textTagPos >= 0) {
+ expect(rightIconPos).toBeGreaterThan(textTagPos)
+ }
+ })
+
+ it('preserves order in nested variant merge with multiple child types', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ // 4 composites: size × varient, with [IconComp, Text, ArrowComp] order
+ // Use unique component names to make assertions easy
+ // ArrowComp must always be last in merged output
+ const makeTree = (
+ textName: string,
+ textContent: string,
+ fontSize: string,
+ ): NodeTree => ({
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'IconComp',
+ props: { id: 'icon' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Icon',
+ },
+ {
+ component: 'Text',
+ props: { fontSize },
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: textName,
+ textChildren: [textContent],
+ },
+ {
+ component: 'ArrowComp',
+ props: { id: 'arrow' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Arrow',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ })
+
+ const bp = 'pc' as import('../index').BreakpointKey
+ const treesByCompositeAndBreakpoint = new Map<
+ string,
+ Map
+ >([
+ [
+ 'size=lg|varient=primary',
+ new Map([[bp, makeTree('LgText', 'Large', '16px')]]),
+ ],
+ [
+ 'size=sm|varient=primary',
+ new Map([[bp, makeTree('SmText', 'Small', '12px')]]),
+ ],
+ [
+ 'size=lg|varient=white',
+ new Map([[bp, makeTree('LgText', 'Large', '16px')]]),
+ ],
+ [
+ 'size=sm|varient=white',
+ new Map([[bp, makeTree('SmText', 'Small', '12px')]]),
+ ],
+ ])
+
+ const result = generator.generateMultiVariantMergedCode(
+ ['size', 'varient'],
+ treesByCompositeAndBreakpoint,
+ 0,
+ )
+
+ // ArrowComp must appear after all text-related content
+ const iconPos = result.indexOf('render:IconComp')
+ const arrowPos = result.indexOf('render:ArrowComp')
+ const lgTextPos = result.indexOf('Large')
+ const smTextPos = result.indexOf('Small')
+
+ expect(iconPos).toBeGreaterThanOrEqual(0)
+ expect(arrowPos).toBeGreaterThan(iconPos)
+ expect(arrowPos).toBeGreaterThan(lgTextPos)
+ expect(arrowPos).toBeGreaterThan(smTextPos)
+ })
+ })
})
diff --git a/src/codegen/utils/extract-instance-variant-props.ts b/src/codegen/utils/extract-instance-variant-props.ts
index 91aee50..f0c7128 100644
--- a/src/codegen/utils/extract-instance-variant-props.ts
+++ b/src/codegen/utils/extract-instance-variant-props.ts
@@ -9,7 +9,7 @@ const RESERVED_VARIANT_KEYS = ['effect', 'viewport']
/**
* Check if a key is a reserved variant key (case-insensitive).
*/
-function isReservedVariantKey(key: string): boolean {
+export function isReservedVariantKey(key: string): boolean {
const lowerKey = key.toLowerCase()
return RESERVED_VARIANT_KEYS.some(
(reserved) => lowerKey === reserved || lowerKey.startsWith(`${reserved}#`),