2026-03-01 09:12:45 +00:00
|
|
|
import { describe, expect, test } from 'bun:test'
|
|
|
|
|
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
import { SceneGraph, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
|
2026-03-01 09:12:45 +00:00
|
|
|
|
|
|
|
|
function makeGraph() {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' })
|
|
|
|
|
return graph
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pageId(graph: SceneGraph) {
|
|
|
|
|
return graph.getPages()[0].id
|
|
|
|
|
}
|
|
|
|
|
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
describe('sceneNodeToJSX', () => {
|
2026-03-01 09:12:45 +00:00
|
|
|
test('basic rectangle', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
name: 'Box',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 50
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toBe('<Rectangle name="Box" w={100} h={50} />')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('rectangle with fill and rounded corners', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
name: 'Card',
|
|
|
|
|
width: 320,
|
|
|
|
|
height: 200,
|
|
|
|
|
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
|
|
|
|
cornerRadius: 16
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('w={320}')
|
|
|
|
|
expect(jsx).toContain('h={200}')
|
|
|
|
|
expect(jsx).toContain('bg="#FFFFFF"')
|
|
|
|
|
expect(jsx).toContain('rounded={16}')
|
|
|
|
|
expect(jsx).toContain('<Rectangle')
|
|
|
|
|
expect(jsx).toContain('/>')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('text node', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Title',
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 24,
|
|
|
|
|
text: 'Hello World',
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
fontWeight: 700,
|
2026-05-03 18:03:40 +00:00
|
|
|
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
2026-03-01 09:12:45 +00:00
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('<Text')
|
|
|
|
|
expect(jsx).toContain('size={18}')
|
|
|
|
|
expect(jsx).toContain('weight="bold"')
|
|
|
|
|
expect(jsx).toContain('color="#000000"')
|
|
|
|
|
expect(jsx).toContain('>Hello World</Text>')
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-29 09:42:30 +00:00
|
|
|
test('rtl text node', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Arabic Title',
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 24,
|
|
|
|
|
text: 'مرحبا',
|
|
|
|
|
textDirection: 'RTL'
|
|
|
|
|
})
|
|
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
|
|
|
|
expect(jsx).toContain('dir="rtl"')
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-01 09:12:45 +00:00
|
|
|
test('frame with auto-layout', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Row',
|
|
|
|
|
width: 400,
|
|
|
|
|
height: 100,
|
|
|
|
|
layoutMode: 'HORIZONTAL',
|
|
|
|
|
itemSpacing: 16,
|
|
|
|
|
paddingTop: 12,
|
|
|
|
|
paddingRight: 12,
|
|
|
|
|
paddingBottom: 12,
|
|
|
|
|
paddingLeft: 12,
|
|
|
|
|
primaryAxisSizing: 'FIXED',
|
|
|
|
|
counterAxisSizing: 'HUG'
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('flex="row"')
|
|
|
|
|
expect(jsx).toContain('gap={16}')
|
|
|
|
|
expect(jsx).toContain('p={12}')
|
|
|
|
|
expect(jsx).toContain('w={400}')
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-29 09:42:30 +00:00
|
|
|
test('frame with rtl auto-layout', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Row RTL',
|
|
|
|
|
width: 400,
|
|
|
|
|
height: 100,
|
|
|
|
|
layoutMode: 'HORIZONTAL',
|
|
|
|
|
layoutDirection: 'RTL'
|
|
|
|
|
})
|
|
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
|
|
|
|
expect(jsx).toContain('dir="rtl"')
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-01 09:12:45 +00:00
|
|
|
test('frame with children', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Card',
|
|
|
|
|
width: 320,
|
|
|
|
|
height: 200,
|
|
|
|
|
layoutMode: 'VERTICAL',
|
|
|
|
|
itemSpacing: 8,
|
|
|
|
|
primaryAxisSizing: 'HUG',
|
|
|
|
|
counterAxisSizing: 'FIXED'
|
|
|
|
|
})
|
|
|
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
|
|
|
name: 'Image',
|
|
|
|
|
width: 320,
|
|
|
|
|
height: 120
|
|
|
|
|
})
|
|
|
|
|
graph.createNode('TEXT', frame.id, {
|
|
|
|
|
name: 'Title',
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Card Title',
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
fontWeight: 700,
|
2026-05-03 18:03:40 +00:00
|
|
|
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
2026-03-01 09:12:45 +00:00
|
|
|
})
|
|
|
|
|
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('<Frame')
|
|
|
|
|
expect(jsx).toContain('flex="col"')
|
|
|
|
|
expect(jsx).toContain('gap={8}')
|
|
|
|
|
expect(jsx).toContain(' <Rectangle')
|
|
|
|
|
expect(jsx).toContain(' <Text')
|
|
|
|
|
expect(jsx).toContain('>Card Title</Text>')
|
|
|
|
|
expect(jsx).toContain('</Frame>')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('asymmetric padding', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Box',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 100,
|
|
|
|
|
layoutMode: 'HORIZONTAL',
|
|
|
|
|
paddingTop: 8,
|
|
|
|
|
paddingRight: 16,
|
|
|
|
|
paddingBottom: 8,
|
|
|
|
|
paddingLeft: 16,
|
|
|
|
|
primaryAxisSizing: 'FIXED',
|
|
|
|
|
counterAxisSizing: 'FIXED'
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('py={8}')
|
|
|
|
|
expect(jsx).toContain('px={16}')
|
|
|
|
|
expect(jsx).not.toContain('pt=')
|
|
|
|
|
expect(jsx).not.toContain('pb=')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('independent corners', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
name: 'Pill',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 40,
|
|
|
|
|
cornerRadius: 20,
|
|
|
|
|
independentCorners: true,
|
|
|
|
|
topLeftRadius: 20,
|
|
|
|
|
topRightRadius: 0,
|
|
|
|
|
bottomRightRadius: 0,
|
|
|
|
|
bottomLeftRadius: 20
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('roundedTL={20}')
|
|
|
|
|
expect(jsx).toContain('roundedBL={20}')
|
|
|
|
|
expect(jsx).not.toContain('roundedTR=')
|
|
|
|
|
expect(jsx).not.toContain('roundedBR=')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('opacity and rotation', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
width: 50,
|
|
|
|
|
height: 50,
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
rotation: 45
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('opacity={0.5}')
|
|
|
|
|
expect(jsx).toContain('rotate={45}')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('stroke', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 100,
|
|
|
|
|
strokes: [
|
2026-05-03 18:03:40 +00:00
|
|
|
{
|
|
|
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
|
|
|
weight: 2,
|
|
|
|
|
opacity: 1,
|
|
|
|
|
visible: true,
|
|
|
|
|
align: 'INSIDE' as const
|
|
|
|
|
}
|
2026-03-01 09:12:45 +00:00
|
|
|
]
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('stroke="#FF0000"')
|
|
|
|
|
expect(jsx).toContain('strokeWidth={2}')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('hidden children are excluded', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Parent',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 100
|
|
|
|
|
})
|
|
|
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
|
|
|
name: 'Visible',
|
|
|
|
|
width: 50,
|
|
|
|
|
height: 50
|
|
|
|
|
})
|
|
|
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
|
|
|
name: 'Hidden',
|
|
|
|
|
width: 50,
|
|
|
|
|
height: 50,
|
|
|
|
|
visible: false
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('Visible')
|
|
|
|
|
expect(jsx).not.toContain('Hidden')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('empty text node renders self-closing', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Empty',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: ''
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('/>')
|
|
|
|
|
expect(jsx).not.toContain('</Text>')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('omits default values', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 100,
|
|
|
|
|
opacity: 1,
|
|
|
|
|
rotation: 0,
|
|
|
|
|
cornerRadius: 0
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).not.toContain('opacity')
|
|
|
|
|
expect(jsx).not.toContain('rotate')
|
|
|
|
|
expect(jsx).not.toContain('rounded')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('effects: shadow and blur', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 100,
|
|
|
|
|
effects: [
|
|
|
|
|
{
|
|
|
|
|
type: 'DROP_SHADOW',
|
|
|
|
|
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
|
|
|
|
offset: { x: 0, y: 4 },
|
|
|
|
|
radius: 8,
|
|
|
|
|
spread: 0,
|
|
|
|
|
visible: true
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'LAYER_BLUR',
|
|
|
|
|
color: { r: 0, g: 0, b: 0, a: 0 },
|
|
|
|
|
offset: { x: 0, y: 0 },
|
|
|
|
|
radius: 4,
|
|
|
|
|
spread: 0,
|
|
|
|
|
visible: true
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = sceneNodeToJSX(node.id, graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('shadow="0 4 8')
|
|
|
|
|
expect(jsx).toContain('blur={4}')
|
|
|
|
|
})
|
CSS Grid layout mode (#73)
* Switch to @open-pencil/yoga-layout with CSS Grid support
Use our fork (open-pencil/yoga, grid branch) which cherry-picks the
upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings
are ported from the old embind approach to the new wasm_bridge.c API.
npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'.
Also handle FinalizationRegistry change (upstream #1908) — node.free()
no longer exists, nodes are garbage collected automatically.
* Add CSS Grid layout mode
Scene graph:
- LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL
- GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions
- GridPosition type (column, row, columnSpan, rowSpan) for children
- New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap,
gridPosition
Layout engine:
- buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's
setGridTemplateColumns/Rows API with FR/Points/Auto track types
- Grid children use gridPosition for column/row placement with span
- Flex layout path unchanged
Store:
- setLayoutMode('GRID') auto-creates NxM track grid based on child
count (sqrt heuristic), defaults to 1fr tracks
UI (LayoutSection.vue):
- Grid button (grid-2x2 icon) added to flow direction row
- Columns/Rows track editors: ScrubInput for value + AppSelect for
sizing mode (Fill fr / Fixed px / Auto), add/remove buttons
- Separate column gap and row gap ScrubInputs
- Wrap button hidden when grid is active
- Flex alignment grid hidden when grid is active
Kiwi serialization skips GRID mode for now (no .fig codec support).
* Add grid layout integration tests
12 new test scenarios covering:
- Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights
- Column gap, row gap, both gaps combined
- Padding offsets
- Explicit gridPosition placement, column span, row span
- Absolute children skipped
- Hidden children collapsed
- Nested grid inside flex parent (computeAllLayouts)
Also fix: grid frames as children of flex parents now correctly use
Display.Grid via configureChildAsGrid() instead of falling through
to configureChildAsAutoLayout() which set FlexDirection.
* Deduplicate grid yoga configuration
Extract configureAsGrid() and createGridChildNode() shared by
buildGridTree (root-level grid) and configureChildAsGrid (grid
nested inside flex parent).
* Add grid layout to changelog
* Add grid support to JSX and Tailwind CSS export
OpenPencil format: grid → columns/rows/columnGap/rowGap props,
child colStart/rowStart/colSpan/rowSpan.
Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*,
mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]),
child col-start-*/row-start-*/col-span-*/row-span-*.
Both formats: padding emitted for grid frames (shared with flex),
flex-only props (justify/items/wrap/gap) scoped to isFlex.
* Remove duplicate gridTrackToTw, use shared formatTrack
Also drop unused GridTrackSizing import from LayoutSection.vue.
* Fix grid icon: use layout-grid instead of grid-2x2
unplugin-icons can't resolve icon names with digits after hyphens
(grid-2x2 → grid2x2 which doesn't exist).
* Polish layout UI and fix auto-layout behavior
- Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding
- Direction-aware gap icon: ↕ for vertical, ↔ for horizontal
- Pin +/− padding toggle button right of gap input
- Fix alignment grid axes for vertical layout (transpose primary/counter)
- Fix grid switch: set FIXED sizing, compute frame size from children
- Remove hardcoded white fill from Shift+A wrap
- Auto-detect horizontal vs vertical from selection bounds
* Add flex-to-grid switch integration test
Verify HUG frame expands and children are placed in 2x2 grid
when switching from vertical flex to grid layout.
* Update changelog for grid layout
2026-03-08 15:23:53 +00:00
|
|
|
|
|
|
|
|
test('grid layout frame', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Grid',
|
|
|
|
|
width: 400,
|
|
|
|
|
height: 300,
|
|
|
|
|
layoutMode: 'GRID',
|
|
|
|
|
gridTemplateColumns: [
|
|
|
|
|
{ sizing: 'FR', value: 1 },
|
|
|
|
|
{ sizing: 'FR', value: 1 },
|
|
|
|
|
{ sizing: 'FR', value: 1 }
|
|
|
|
|
],
|
|
|
|
|
gridTemplateRows: [
|
|
|
|
|
{ sizing: 'FR', value: 1 },
|
|
|
|
|
{ sizing: 'FR', value: 1 }
|
|
|
|
|
],
|
|
|
|
|
gridColumnGap: 16,
|
|
|
|
|
gridRowGap: 8,
|
|
|
|
|
paddingTop: 12,
|
|
|
|
|
paddingRight: 12,
|
|
|
|
|
paddingBottom: 12,
|
|
|
|
|
paddingLeft: 12
|
|
|
|
|
})
|
|
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
|
|
|
|
expect(jsx).toContain('grid')
|
|
|
|
|
expect(jsx).toContain('columns="1fr 1fr 1fr"')
|
|
|
|
|
expect(jsx).toContain('rows="1fr 1fr"')
|
|
|
|
|
expect(jsx).toContain('columnGap={16}')
|
|
|
|
|
expect(jsx).toContain('rowGap={8}')
|
|
|
|
|
expect(jsx).toContain('p={12}')
|
|
|
|
|
expect(jsx).toContain('w={400}')
|
|
|
|
|
expect(jsx).not.toContain('flex')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('grid with mixed track sizes', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
width: 600,
|
|
|
|
|
height: 400,
|
|
|
|
|
layoutMode: 'GRID',
|
|
|
|
|
gridTemplateColumns: [
|
|
|
|
|
{ sizing: 'FIXED', value: 200 },
|
|
|
|
|
{ sizing: 'FR', value: 1 },
|
|
|
|
|
{ sizing: 'AUTO', value: 0 }
|
|
|
|
|
],
|
|
|
|
|
gridTemplateRows: [],
|
|
|
|
|
gridColumnGap: 0,
|
|
|
|
|
gridRowGap: 0
|
|
|
|
|
})
|
|
|
|
|
const jsx = sceneNodeToJSX(frame.id, graph)
|
|
|
|
|
expect(jsx).toContain('columns="200px 1fr auto"')
|
|
|
|
|
expect(jsx).not.toContain('rows=')
|
|
|
|
|
expect(jsx).not.toContain('columnGap')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('child grid position', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
width: 400,
|
|
|
|
|
height: 300,
|
|
|
|
|
layoutMode: 'GRID',
|
|
|
|
|
gridTemplateColumns: [
|
|
|
|
|
{ sizing: 'FR', value: 1 },
|
|
|
|
|
{ sizing: 'FR', value: 1 }
|
|
|
|
|
],
|
|
|
|
|
gridTemplateRows: [
|
|
|
|
|
{ sizing: 'FR', value: 1 },
|
|
|
|
|
{ sizing: 'FR', value: 1 }
|
|
|
|
|
],
|
|
|
|
|
gridColumnGap: 0,
|
|
|
|
|
gridRowGap: 0
|
|
|
|
|
})
|
|
|
|
|
const child = graph.createNode('RECTANGLE', frame.id, {
|
|
|
|
|
name: 'Span',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 50,
|
|
|
|
|
gridPosition: { column: 1, row: 2, columnSpan: 2, rowSpan: 1 }
|
|
|
|
|
})
|
|
|
|
|
const jsx = sceneNodeToJSX(child.id, graph)
|
|
|
|
|
expect(jsx).toContain('colStart={1}')
|
|
|
|
|
expect(jsx).toContain('rowStart={2}')
|
|
|
|
|
expect(jsx).toContain('colSpan={2}')
|
|
|
|
|
expect(jsx).not.toContain('rowSpan')
|
|
|
|
|
})
|
2026-03-01 09:12:45 +00:00
|
|
|
})
|
|
|
|
|
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
describe('selectionToJSX', () => {
|
2026-03-01 09:12:45 +00:00
|
|
|
test('multiple nodes separated by blank lines', () => {
|
|
|
|
|
const graph = makeGraph()
|
|
|
|
|
const a = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
name: 'A',
|
|
|
|
|
width: 10,
|
|
|
|
|
height: 10
|
|
|
|
|
})
|
|
|
|
|
const b = graph.createNode('ELLIPSE', pageId(graph), {
|
|
|
|
|
name: 'B',
|
|
|
|
|
width: 20,
|
|
|
|
|
height: 20
|
|
|
|
|
})
|
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
2026-03-06 15:04:11 +00:00
|
|
|
const jsx = selectionToJSX([a.id, b.id], graph)
|
2026-03-01 09:12:45 +00:00
|
|
|
expect(jsx).toContain('<Rectangle')
|
|
|
|
|
expect(jsx).toContain('<Ellipse')
|
|
|
|
|
expect(jsx).toContain('\n\n')
|
|
|
|
|
})
|
|
|
|
|
})
|