@zseven-w/pen-core 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -9
- package/package.json +5 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +158 -111
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -230
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +133 -105
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +79 -79
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +107 -102
package/README.md
CHANGED
|
@@ -23,16 +23,16 @@ import {
|
|
|
23
23
|
updateNodeInTree,
|
|
24
24
|
deepCloneNode,
|
|
25
25
|
flattenNodes,
|
|
26
|
-
} from '@zseven-w/pen-core'
|
|
26
|
+
} from '@zseven-w/pen-core';
|
|
27
27
|
|
|
28
|
-
const doc = createEmptyDocument()
|
|
29
|
-
const node = findNodeInTree(doc.children, 'node-id')
|
|
28
|
+
const doc = createEmptyDocument();
|
|
29
|
+
const node = findNodeInTree(doc.children, 'node-id');
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
### Multi-Page Support
|
|
33
33
|
|
|
34
34
|
```ts
|
|
35
|
-
import { getActivePage, getActivePageChildren, migrateToPages } from '@zseven-w/pen-core'
|
|
35
|
+
import { getActivePage, getActivePageChildren, migrateToPages } from '@zseven-w/pen-core';
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
### Layout Engine
|
|
@@ -40,7 +40,12 @@ import { getActivePage, getActivePageChildren, migrateToPages } from '@zseven-w/
|
|
|
40
40
|
Automatic layout computation with auto-sizing, padding, and gap support:
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
inferLayout,
|
|
45
|
+
computeLayoutPositions,
|
|
46
|
+
fitContentWidth,
|
|
47
|
+
fitContentHeight,
|
|
48
|
+
} from '@zseven-w/pen-core';
|
|
44
49
|
```
|
|
45
50
|
|
|
46
51
|
### Design Variables
|
|
@@ -48,7 +53,11 @@ import { inferLayout, computeLayoutPositions, fitContentWidth, fitContentHeight
|
|
|
48
53
|
Resolve `$variable` references against theme axes:
|
|
49
54
|
|
|
50
55
|
```ts
|
|
51
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
resolveVariableRef,
|
|
58
|
+
resolveNodeForCanvas,
|
|
59
|
+
replaceVariableRefsInTree,
|
|
60
|
+
} from '@zseven-w/pen-core';
|
|
52
61
|
```
|
|
53
62
|
|
|
54
63
|
### Boolean Path Operations
|
|
@@ -56,7 +65,7 @@ import { resolveVariableRef, resolveNodeForCanvas, replaceVariableRefsInTree } f
|
|
|
56
65
|
Union, subtract, intersect, and exclude paths via Paper.js:
|
|
57
66
|
|
|
58
67
|
```ts
|
|
59
|
-
import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core'
|
|
68
|
+
import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core';
|
|
60
69
|
```
|
|
61
70
|
|
|
62
71
|
### Text Measurement
|
|
@@ -64,7 +73,7 @@ import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core'
|
|
|
64
73
|
Estimate text dimensions for layout without a browser:
|
|
65
74
|
|
|
66
75
|
```ts
|
|
67
|
-
import { estimateTextWidth, estimateTextHeight } from '@zseven-w/pen-core'
|
|
76
|
+
import { estimateTextWidth, estimateTextHeight } from '@zseven-w/pen-core';
|
|
68
77
|
```
|
|
69
78
|
|
|
70
79
|
### Document Normalization
|
|
@@ -72,7 +81,7 @@ import { estimateTextWidth, estimateTextHeight } from '@zseven-w/pen-core'
|
|
|
72
81
|
Sanitize and fix documents imported from external sources:
|
|
73
82
|
|
|
74
83
|
```ts
|
|
75
|
-
import { normalizePenDocument } from '@zseven-w/pen-core'
|
|
84
|
+
import { normalizePenDocument } from '@zseven-w/pen-core';
|
|
76
85
|
```
|
|
77
86
|
|
|
78
87
|
## License
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zseven-w/pen-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
|
|
5
|
+
"files": [
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
5
8
|
"type": "module",
|
|
6
9
|
"exports": {
|
|
7
10
|
".": {
|
|
@@ -9,14 +12,11 @@
|
|
|
9
12
|
"import": "./src/index.ts"
|
|
10
13
|
}
|
|
11
14
|
},
|
|
12
|
-
"files": [
|
|
13
|
-
"src"
|
|
14
|
-
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zseven-w/pen-types": "0.
|
|
19
|
+
"@zseven-w/pen-types": "0.7.0",
|
|
20
20
|
"nanoid": "^5.1.6",
|
|
21
21
|
"paper": "^0.12.18"
|
|
22
22
|
},
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildEllipseArcPath, isArcEllipse } from '../arc-path'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildEllipseArcPath, isArcEllipse } from '../arc-path';
|
|
3
3
|
|
|
4
4
|
describe('arc-path', () => {
|
|
5
5
|
describe('isArcEllipse', () => {
|
|
6
6
|
it('returns false for full circle with no inner radius', () => {
|
|
7
|
-
expect(isArcEllipse(0, 360, 0)).toBe(false)
|
|
8
|
-
})
|
|
7
|
+
expect(isArcEllipse(0, 360, 0)).toBe(false);
|
|
8
|
+
});
|
|
9
9
|
|
|
10
10
|
it('returns true for partial sweep', () => {
|
|
11
|
-
expect(isArcEllipse(0, 180, 0)).toBe(true)
|
|
12
|
-
})
|
|
11
|
+
expect(isArcEllipse(0, 180, 0)).toBe(true);
|
|
12
|
+
});
|
|
13
13
|
|
|
14
14
|
it('returns true for inner radius (donut)', () => {
|
|
15
|
-
expect(isArcEllipse(0, 360, 0.5)).toBe(true)
|
|
16
|
-
})
|
|
17
|
-
})
|
|
15
|
+
expect(isArcEllipse(0, 360, 0.5)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
18
|
|
|
19
19
|
describe('buildEllipseArcPath', () => {
|
|
20
20
|
it('builds a full circle path', () => {
|
|
21
|
-
const path = buildEllipseArcPath(100, 100, 0, 360, 0)
|
|
22
|
-
expect(path).toContain('M')
|
|
23
|
-
expect(path).toContain('A')
|
|
24
|
-
expect(path).toContain('Z')
|
|
25
|
-
})
|
|
21
|
+
const path = buildEllipseArcPath(100, 100, 0, 360, 0);
|
|
22
|
+
expect(path).toContain('M');
|
|
23
|
+
expect(path).toContain('A');
|
|
24
|
+
expect(path).toContain('Z');
|
|
25
|
+
});
|
|
26
26
|
|
|
27
27
|
it('builds a pie slice path', () => {
|
|
28
|
-
const path = buildEllipseArcPath(100, 100, 0, 90, 0)
|
|
29
|
-
expect(path).toContain('M50 50') // center point for pie
|
|
30
|
-
expect(path).toContain('Z')
|
|
31
|
-
})
|
|
28
|
+
const path = buildEllipseArcPath(100, 100, 0, 90, 0);
|
|
29
|
+
expect(path).toContain('M50 50'); // center point for pie
|
|
30
|
+
expect(path).toContain('Z');
|
|
31
|
+
});
|
|
32
32
|
|
|
33
33
|
it('builds a donut path with inner radius', () => {
|
|
34
|
-
const path = buildEllipseArcPath(100, 100, 0, 360, 0.5)
|
|
34
|
+
const path = buildEllipseArcPath(100, 100, 0, 360, 0.5);
|
|
35
35
|
// Should have inner arc commands
|
|
36
|
-
expect(path.split('A').length).toBeGreaterThanOrEqual(3) // outer + inner arcs
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
-
})
|
|
36
|
+
expect(path.split('A').length).toBeGreaterThanOrEqual(3); // outer + inner arcs
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeName, nodeTreeToSummary } from '../index';
|
|
3
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
4
|
+
|
|
5
|
+
describe('sanitizeName', () => {
|
|
6
|
+
it('converts kebab-case to PascalCase', () => {
|
|
7
|
+
expect(sanitizeName('hero-section')).toBe('HeroSection');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('strips non-alphanumeric characters', () => {
|
|
11
|
+
expect(sanitizeName('Card #1 (main)')).toBe('Card1Main');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('handles empty string', () => {
|
|
15
|
+
expect(sanitizeName('')).toBe('');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('nodeTreeToSummary', () => {
|
|
20
|
+
it('renders single node', () => {
|
|
21
|
+
const nodes: PenNode[] = [
|
|
22
|
+
{ id: 'n1', type: 'frame', name: 'Hero', width: 800, height: 600 } as PenNode,
|
|
23
|
+
];
|
|
24
|
+
const result = nodeTreeToSummary(nodes);
|
|
25
|
+
expect(result).toContain('[n1]');
|
|
26
|
+
expect(result).toContain('frame');
|
|
27
|
+
expect(result).toContain('"Hero"');
|
|
28
|
+
expect(result).toContain('800x600');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders nested children with indentation', () => {
|
|
32
|
+
const nodes: PenNode[] = [
|
|
33
|
+
{
|
|
34
|
+
id: 'p',
|
|
35
|
+
type: 'frame',
|
|
36
|
+
name: 'Parent',
|
|
37
|
+
width: 100,
|
|
38
|
+
height: 100,
|
|
39
|
+
children: [{ id: 'c', type: 'rectangle', name: 'Child', width: 50, height: 50 } as PenNode],
|
|
40
|
+
} as unknown as PenNode,
|
|
41
|
+
];
|
|
42
|
+
const result = nodeTreeToSummary(nodes);
|
|
43
|
+
expect(result).toContain('[1 children]');
|
|
44
|
+
expect(result).toContain(' - [c]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns empty string for empty array', () => {
|
|
48
|
+
expect(nodeTreeToSummary([])).toBe('');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseDesignMd, generateDesignMd, designMdColorsToVariables } from '../design-md-parser';
|
|
3
|
+
|
|
4
|
+
describe('parseDesignMd', () => {
|
|
5
|
+
it('should parse a basic design.md with color palette', () => {
|
|
6
|
+
const md = `# My App\n\n## Color Palette\n- primary: #3B82F6 (main brand)\n- secondary: #10B981 (accents)\n\n## Typography\nFont family: Inter\nHeadings: 700 weight\nBody: 400 weight\nScale: 1.25\n`;
|
|
7
|
+
const result = parseDesignMd(md);
|
|
8
|
+
expect(result.projectName).toBe('My App');
|
|
9
|
+
expect(result.colorPalette).toHaveLength(2);
|
|
10
|
+
expect(result.colorPalette![0]).toMatchObject({ name: 'primary', hex: '#3B82F6' });
|
|
11
|
+
expect(result.typography?.fontFamily).toBe('Inter');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return empty spec for empty input', () => {
|
|
15
|
+
const result = parseDesignMd('');
|
|
16
|
+
expect(result.projectName).toBeUndefined();
|
|
17
|
+
expect(result.colorPalette).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should handle fuzzy section headers', () => {
|
|
21
|
+
const md = `# Test\n\n## Visual Theme & Atmosphere\nMinimal, clean\n\n## Colour Roles\n- bg: #FFFFFF (background)\n`;
|
|
22
|
+
const result = parseDesignMd(md);
|
|
23
|
+
expect(result.visualTheme).toContain('Minimal');
|
|
24
|
+
expect(result.colorPalette).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('generateDesignMd', () => {
|
|
29
|
+
it('should roundtrip parse → generate → parse', () => {
|
|
30
|
+
const md = `# Roundtrip\n\n## Color Palette\n- primary: #FF0000 (brand)\n- text: #333333 (body text)\n\n## Typography\nFont family: Roboto\nHeadings: 700 weight\nBody: 400 weight\nScale: 1.2\n`;
|
|
31
|
+
const parsed = parseDesignMd(md);
|
|
32
|
+
const generated = generateDesignMd(parsed);
|
|
33
|
+
const reparsed = parseDesignMd(generated);
|
|
34
|
+
expect(reparsed.colorPalette).toHaveLength(parsed.colorPalette!.length);
|
|
35
|
+
expect(reparsed.typography?.fontFamily).toBe(parsed.typography?.fontFamily);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('designMdColorsToVariables', () => {
|
|
40
|
+
it('should convert colors to variable definitions', () => {
|
|
41
|
+
const colors = [
|
|
42
|
+
{ name: 'primary', hex: '#3B82F6', role: 'main brand' },
|
|
43
|
+
{ name: 'bg', hex: '#FFFFFF', role: 'background' },
|
|
44
|
+
];
|
|
45
|
+
const vars = designMdColorsToVariables(colors);
|
|
46
|
+
expect(vars['primary']).toMatchObject({ type: 'color', value: '#3B82F6' });
|
|
47
|
+
expect(vars['bg']).toMatchObject({ type: 'color', value: '#FFFFFF' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { cssFontFamily } from '../font-utils'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cssFontFamily } from '../font-utils';
|
|
3
3
|
|
|
4
4
|
describe('cssFontFamily', () => {
|
|
5
5
|
it('quotes multi-word font names', () => {
|
|
6
|
-
expect(cssFontFamily('Noto Sans SC')).toBe('"Noto Sans SC"')
|
|
7
|
-
})
|
|
6
|
+
expect(cssFontFamily('Noto Sans SC')).toBe('"Noto Sans SC"');
|
|
7
|
+
});
|
|
8
8
|
|
|
9
9
|
it('does not quote generic families', () => {
|
|
10
|
-
expect(cssFontFamily('sans-serif')).toBe('sans-serif')
|
|
11
|
-
expect(cssFontFamily('monospace')).toBe('monospace')
|
|
12
|
-
expect(cssFontFamily('system-ui')).toBe('system-ui')
|
|
13
|
-
})
|
|
10
|
+
expect(cssFontFamily('sans-serif')).toBe('sans-serif');
|
|
11
|
+
expect(cssFontFamily('monospace')).toBe('monospace');
|
|
12
|
+
expect(cssFontFamily('system-ui')).toBe('system-ui');
|
|
13
|
+
});
|
|
14
14
|
|
|
15
15
|
it('handles comma-separated lists', () => {
|
|
16
|
-
expect(cssFontFamily('Inter, sans-serif')).toBe('"Inter", sans-serif')
|
|
17
|
-
})
|
|
16
|
+
expect(cssFontFamily('Inter, sans-serif')).toBe('"Inter", sans-serif');
|
|
17
|
+
});
|
|
18
18
|
|
|
19
19
|
it('preserves already-quoted names', () => {
|
|
20
|
-
expect(cssFontFamily('"Noto Sans SC"')).toBe('"Noto Sans SC"')
|
|
21
|
-
})
|
|
20
|
+
expect(cssFontFamily('"Noto Sans SC"')).toBe('"Noto Sans SC"');
|
|
21
|
+
});
|
|
22
22
|
|
|
23
23
|
it('handles -apple-system', () => {
|
|
24
|
-
expect(cssFontFamily('-apple-system')).toBe('-apple-system')
|
|
25
|
-
})
|
|
26
|
-
})
|
|
24
|
+
expect(cssFontFamily('-apple-system')).toBe('-apple-system');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type { PenNode } from '@zseven-w/pen-types'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
3
|
import {
|
|
4
4
|
resolvePadding,
|
|
5
5
|
isNodeVisible,
|
|
@@ -7,147 +7,233 @@ import {
|
|
|
7
7
|
getNodeWidth,
|
|
8
8
|
getNodeHeight,
|
|
9
9
|
computeLayoutPositions,
|
|
10
|
-
} from '../layout/engine'
|
|
10
|
+
} from '../layout/engine';
|
|
11
11
|
|
|
12
|
-
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
|
|
13
|
+
({
|
|
14
|
+
id: 'f1',
|
|
15
|
+
type: 'frame',
|
|
16
|
+
x: 0,
|
|
17
|
+
y: 0,
|
|
18
|
+
...props,
|
|
19
|
+
}) as PenNode;
|
|
15
20
|
|
|
16
21
|
const rect = (id: string, w = 50, h = 30): PenNode => ({
|
|
17
|
-
id,
|
|
18
|
-
|
|
22
|
+
id,
|
|
23
|
+
type: 'rectangle',
|
|
24
|
+
x: 0,
|
|
25
|
+
y: 0,
|
|
26
|
+
width: w,
|
|
27
|
+
height: h,
|
|
28
|
+
});
|
|
19
29
|
|
|
20
30
|
describe('layout-engine', () => {
|
|
21
31
|
describe('resolvePadding', () => {
|
|
22
32
|
it('returns zero for undefined', () => {
|
|
23
|
-
expect(resolvePadding(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
|
|
24
|
-
})
|
|
33
|
+
expect(resolvePadding(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
34
|
+
});
|
|
25
35
|
|
|
26
36
|
it('resolves uniform padding', () => {
|
|
27
|
-
expect(resolvePadding(10)).toEqual({ top: 10, right: 10, bottom: 10, left: 10 })
|
|
28
|
-
})
|
|
37
|
+
expect(resolvePadding(10)).toEqual({ top: 10, right: 10, bottom: 10, left: 10 });
|
|
38
|
+
});
|
|
29
39
|
|
|
30
40
|
it('resolves [vertical, horizontal]', () => {
|
|
31
|
-
expect(resolvePadding([10, 20])).toEqual({ top: 10, right: 20, bottom: 10, left: 20 })
|
|
32
|
-
})
|
|
41
|
+
expect(resolvePadding([10, 20])).toEqual({ top: 10, right: 20, bottom: 10, left: 20 });
|
|
42
|
+
});
|
|
33
43
|
|
|
34
44
|
it('resolves [top, right, bottom, left]', () => {
|
|
35
|
-
expect(resolvePadding([1, 2, 3, 4])).toEqual({ top: 1, right: 2, bottom: 3, left: 4 })
|
|
36
|
-
})
|
|
45
|
+
expect(resolvePadding([1, 2, 3, 4])).toEqual({ top: 1, right: 2, bottom: 3, left: 4 });
|
|
46
|
+
});
|
|
37
47
|
|
|
38
48
|
it('returns zero for string (variable ref)', () => {
|
|
39
|
-
expect(resolvePadding('$spacing')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
|
|
40
|
-
})
|
|
41
|
-
})
|
|
49
|
+
expect(resolvePadding('$spacing')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
42
52
|
|
|
43
53
|
describe('isNodeVisible', () => {
|
|
44
54
|
it('returns true by default', () => {
|
|
45
|
-
expect(isNodeVisible(rect('a'))).toBe(true)
|
|
46
|
-
})
|
|
55
|
+
expect(isNodeVisible(rect('a'))).toBe(true);
|
|
56
|
+
});
|
|
47
57
|
|
|
48
58
|
it('returns false when visible is false', () => {
|
|
49
|
-
expect(isNodeVisible({ ...rect('a'), visible: false })).toBe(false)
|
|
50
|
-
})
|
|
51
|
-
|
|
59
|
+
expect(isNodeVisible({ ...rect('a'), visible: false })).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns false when enabled is false', () => {
|
|
63
|
+
expect(isNodeVisible({ ...rect('a'), enabled: false } as PenNode)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
52
66
|
|
|
53
67
|
describe('inferLayout', () => {
|
|
54
68
|
it('returns undefined for non-frame nodes', () => {
|
|
55
|
-
expect(inferLayout(rect('a'))).toBeUndefined()
|
|
56
|
-
})
|
|
69
|
+
expect(inferLayout(rect('a'))).toBeUndefined();
|
|
70
|
+
});
|
|
57
71
|
|
|
58
72
|
it('infers horizontal when gap is set', () => {
|
|
59
|
-
expect(inferLayout(frame({ gap: 10, children: [] }))).toBe('horizontal')
|
|
60
|
-
})
|
|
73
|
+
expect(inferLayout(frame({ gap: 10, children: [] }))).toBe('horizontal');
|
|
74
|
+
});
|
|
61
75
|
|
|
62
76
|
it('infers horizontal when padding is set', () => {
|
|
63
|
-
expect(inferLayout(frame({ padding: 10, children: [] }))).toBe('horizontal')
|
|
64
|
-
})
|
|
77
|
+
expect(inferLayout(frame({ padding: 10, children: [] }))).toBe('horizontal');
|
|
78
|
+
});
|
|
65
79
|
|
|
66
80
|
it('returns undefined when no layout hints', () => {
|
|
67
|
-
expect(inferLayout(frame({ children: [rect('a')] }))).toBeUndefined()
|
|
68
|
-
})
|
|
69
|
-
})
|
|
81
|
+
expect(inferLayout(frame({ children: [rect('a')] }))).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
70
84
|
|
|
71
85
|
describe('getNodeWidth / getNodeHeight', () => {
|
|
72
86
|
it('returns explicit width', () => {
|
|
73
|
-
expect(getNodeWidth(rect('a', 200))).toBe(200)
|
|
74
|
-
})
|
|
87
|
+
expect(getNodeWidth(rect('a', 200))).toBe(200);
|
|
88
|
+
});
|
|
75
89
|
|
|
76
90
|
it('returns explicit height', () => {
|
|
77
|
-
expect(getNodeHeight(rect('a', 50, 100))).toBe(100)
|
|
78
|
-
})
|
|
91
|
+
expect(getNodeHeight(rect('a', 50, 100))).toBe(100);
|
|
92
|
+
});
|
|
79
93
|
|
|
80
94
|
it('estimates text width', () => {
|
|
81
|
-
const text: PenNode = { id: 't', type: 'text', content: 'Hello World', fontSize: 16 }
|
|
82
|
-
expect(getNodeWidth(text)).toBeGreaterThan(0)
|
|
83
|
-
})
|
|
84
|
-
})
|
|
95
|
+
const text: PenNode = { id: 't', type: 'text', content: 'Hello World', fontSize: 16 };
|
|
96
|
+
expect(getNodeWidth(text)).toBeGreaterThan(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
85
99
|
|
|
86
100
|
describe('computeLayoutPositions', () => {
|
|
87
101
|
it('positions children horizontally', () => {
|
|
88
102
|
const parent = frame({
|
|
89
|
-
width: 300,
|
|
90
|
-
|
|
103
|
+
width: 300,
|
|
104
|
+
height: 100,
|
|
105
|
+
layout: 'horizontal',
|
|
106
|
+
gap: 10,
|
|
91
107
|
children: [rect('a', 50, 30), rect('b', 50, 30)],
|
|
92
|
-
})
|
|
93
|
-
const result = computeLayoutPositions(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
});
|
|
109
|
+
const result = computeLayoutPositions(
|
|
110
|
+
parent,
|
|
111
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
112
|
+
);
|
|
113
|
+
expect(result[0].x).toBe(0);
|
|
114
|
+
expect(result[0].y).toBe(0);
|
|
115
|
+
expect(result[1].x).toBe(60); // 50 + 10 gap
|
|
116
|
+
});
|
|
98
117
|
|
|
99
118
|
it('positions children vertically', () => {
|
|
100
119
|
const parent = frame({
|
|
101
|
-
width: 100,
|
|
102
|
-
|
|
120
|
+
width: 100,
|
|
121
|
+
height: 300,
|
|
122
|
+
layout: 'vertical',
|
|
123
|
+
gap: 10,
|
|
103
124
|
children: [rect('a', 50, 30), rect('b', 50, 30)],
|
|
104
|
-
})
|
|
105
|
-
const result = computeLayoutPositions(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
});
|
|
126
|
+
const result = computeLayoutPositions(
|
|
127
|
+
parent,
|
|
128
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
129
|
+
);
|
|
130
|
+
expect(result[0].x).toBe(0);
|
|
131
|
+
expect(result[0].y).toBe(0);
|
|
132
|
+
expect(result[1].y).toBe(40); // 30 + 10 gap
|
|
133
|
+
});
|
|
110
134
|
|
|
111
135
|
it('applies padding', () => {
|
|
112
136
|
const parent = frame({
|
|
113
|
-
width: 300,
|
|
114
|
-
|
|
137
|
+
width: 300,
|
|
138
|
+
height: 100,
|
|
139
|
+
layout: 'horizontal',
|
|
140
|
+
padding: 20,
|
|
115
141
|
children: [rect('a', 50, 30)],
|
|
116
|
-
})
|
|
117
|
-
const result = computeLayoutPositions(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
142
|
+
});
|
|
143
|
+
const result = computeLayoutPositions(
|
|
144
|
+
parent,
|
|
145
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
146
|
+
);
|
|
147
|
+
expect(result[0].x).toBe(20);
|
|
148
|
+
expect(result[0].y).toBe(20);
|
|
149
|
+
});
|
|
121
150
|
|
|
122
151
|
it('centers children on cross axis', () => {
|
|
123
152
|
const parent = frame({
|
|
124
|
-
width: 300,
|
|
125
|
-
|
|
153
|
+
width: 300,
|
|
154
|
+
height: 100,
|
|
155
|
+
layout: 'horizontal',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
children: [rect('a', 50, 30)],
|
|
158
|
+
});
|
|
159
|
+
const result = computeLayoutPositions(
|
|
160
|
+
parent,
|
|
161
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
162
|
+
);
|
|
163
|
+
expect(result[0].y).toBe(35); // (100 - 30) / 2
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('maps alignItems="baseline" to end-alignment (engine has no baseline metric)', () => {
|
|
167
|
+
// LLMs routinely emit alignItems: 'baseline' from web CSS reflex
|
|
168
|
+
// for "big number + small unit" patterns like "72 BPM". The
|
|
169
|
+
// layout engine doesn't compute text baselines; the closest
|
|
170
|
+
// visually correct fallback is end-alignment (both children
|
|
171
|
+
// bottom-pinned). Locks in that `baseline` no longer falls
|
|
172
|
+
// through to the `start` default.
|
|
173
|
+
//
|
|
174
|
+
// `baseline` is not part of the TS `alignItems` union, so we
|
|
175
|
+
// cast through unknown — the normalizer accepts any string and
|
|
176
|
+
// that's exactly the behavior we want to exercise here.
|
|
177
|
+
const parent = frame({
|
|
178
|
+
width: 300,
|
|
179
|
+
height: 100,
|
|
180
|
+
layout: 'horizontal',
|
|
181
|
+
alignItems: 'baseline' as unknown as 'start' | 'center' | 'end',
|
|
182
|
+
children: [rect('big', 120, 80), rect('unit', 60, 20)],
|
|
183
|
+
});
|
|
184
|
+
const result = computeLayoutPositions(
|
|
185
|
+
parent,
|
|
186
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
187
|
+
);
|
|
188
|
+
expect(result[0].y).toBe(20); // 100 - 80 (big pinned to bottom)
|
|
189
|
+
expect(result[1].y).toBe(80); // 100 - 20 (unit pinned to bottom)
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('maps alignItems="flex-end" and "bottom" to end (CSS/alias passthrough)', () => {
|
|
193
|
+
// `flex-end` is a CSS alias that the normalizer accepts but the
|
|
194
|
+
// TS union doesn't — cast through unknown same as the baseline
|
|
195
|
+
// test above.
|
|
196
|
+
const parent = frame({
|
|
197
|
+
width: 300,
|
|
198
|
+
height: 100,
|
|
199
|
+
layout: 'horizontal',
|
|
200
|
+
alignItems: 'flex-end' as unknown as 'start' | 'center' | 'end',
|
|
126
201
|
children: [rect('a', 50, 30)],
|
|
127
|
-
})
|
|
128
|
-
const result = computeLayoutPositions(
|
|
129
|
-
|
|
130
|
-
|
|
202
|
+
});
|
|
203
|
+
const result = computeLayoutPositions(
|
|
204
|
+
parent,
|
|
205
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
206
|
+
);
|
|
207
|
+
expect(result[0].y).toBe(70); // 100 - 30
|
|
208
|
+
});
|
|
131
209
|
|
|
132
210
|
it('filters invisible children', () => {
|
|
133
211
|
const parent = frame({
|
|
134
|
-
width: 300,
|
|
212
|
+
width: 300,
|
|
213
|
+
height: 100,
|
|
135
214
|
layout: 'horizontal',
|
|
136
215
|
children: [rect('a', 50, 30), { ...rect('b', 50, 30), visible: false }],
|
|
137
|
-
})
|
|
138
|
-
const result = computeLayoutPositions(
|
|
139
|
-
|
|
140
|
-
|
|
216
|
+
});
|
|
217
|
+
const result = computeLayoutPositions(
|
|
218
|
+
parent,
|
|
219
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
220
|
+
);
|
|
221
|
+
expect(result).toHaveLength(1);
|
|
222
|
+
});
|
|
141
223
|
|
|
142
224
|
it('returns visible children as-is when layout is none', () => {
|
|
143
225
|
const parent = frame({
|
|
144
|
-
width: 300,
|
|
226
|
+
width: 300,
|
|
227
|
+
height: 100,
|
|
145
228
|
layout: 'none',
|
|
146
229
|
children: [rect('a', 50, 30)],
|
|
147
|
-
})
|
|
148
|
-
const result = computeLayoutPositions(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
230
|
+
});
|
|
231
|
+
const result = computeLayoutPositions(
|
|
232
|
+
parent,
|
|
233
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
234
|
+
);
|
|
235
|
+
expect(result).toHaveLength(1);
|
|
236
|
+
expect(result[0].id).toBe('a');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|