@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.
Files changed (45) hide show
  1. package/README.md +18 -9
  2. package/package.json +5 -5
  3. package/src/__tests__/arc-path.test.ts +23 -23
  4. package/src/__tests__/codegen-utils.test.ts +50 -0
  5. package/src/__tests__/design-md-parser.test.ts +49 -0
  6. package/src/__tests__/font-utils.test.ts +15 -15
  7. package/src/__tests__/layout-engine.test.ts +169 -83
  8. package/src/__tests__/merge-helpers.test.ts +143 -0
  9. package/src/__tests__/node-diff.test.ts +139 -0
  10. package/src/__tests__/node-helpers.test.ts +19 -19
  11. package/src/__tests__/node-merge.test.ts +425 -0
  12. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  13. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  14. package/src/__tests__/normalize.test.ts +119 -80
  15. package/src/__tests__/path-anchors.test.ts +98 -0
  16. package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
  17. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  18. package/src/__tests__/text-measure.test.ts +84 -79
  19. package/src/__tests__/tree-utils.test.ts +133 -102
  20. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  21. package/src/__tests__/variables.test.ts +68 -65
  22. package/src/arc-path.ts +35 -35
  23. package/src/boolean-ops.ts +158 -111
  24. package/src/constants.ts +36 -36
  25. package/src/design-md-parser.ts +363 -0
  26. package/src/font-utils.ts +30 -15
  27. package/src/id.ts +1 -1
  28. package/src/index.ts +47 -13
  29. package/src/layout/engine.ts +255 -230
  30. package/src/layout/normalize-tree.ts +140 -0
  31. package/src/layout/strip-redundant-section-fills.ts +155 -0
  32. package/src/layout/text-measure.ts +133 -105
  33. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  34. package/src/merge/index.ts +16 -0
  35. package/src/merge/merge-helpers.ts +113 -0
  36. package/src/merge/node-diff.ts +123 -0
  37. package/src/merge/node-merge.ts +651 -0
  38. package/src/node-helpers.ts +18 -5
  39. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  40. package/src/normalize.ts +79 -79
  41. package/src/path-anchors.ts +331 -0
  42. package/src/sync-lock.ts +3 -3
  43. package/src/tree-utils.ts +180 -158
  44. package/src/variables/replace-refs.ts +63 -60
  45. 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 { inferLayout, computeLayoutPositions, fitContentWidth, fitContentHeight } from '@zseven-w/pen-core'
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 { resolveVariableRef, resolveNodeForCanvas, replaceVariableRefsInTree } from '@zseven-w/pen-core'
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.5.2",
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.5.2",
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
- id: 'f1', type: 'frame', x: 0, y: 0, ...props,
14
- } as PenNode)
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, type: 'rectangle', x: 0, y: 0, width: w, height: h,
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, height: 100,
90
- layout: 'horizontal', gap: 10,
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(parent, (parent as PenNode & { children: PenNode[] }).children)
94
- expect(result[0].x).toBe(0)
95
- expect(result[0].y).toBe(0)
96
- expect(result[1].x).toBe(60) // 50 + 10 gap
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, height: 300,
102
- layout: 'vertical', gap: 10,
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(parent, (parent as PenNode & { children: PenNode[] }).children)
106
- expect(result[0].x).toBe(0)
107
- expect(result[0].y).toBe(0)
108
- expect(result[1].y).toBe(40) // 30 + 10 gap
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, height: 100,
114
- layout: 'horizontal', padding: 20,
137
+ width: 300,
138
+ height: 100,
139
+ layout: 'horizontal',
140
+ padding: 20,
115
141
  children: [rect('a', 50, 30)],
116
- })
117
- const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
118
- expect(result[0].x).toBe(20)
119
- expect(result[0].y).toBe(20)
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, height: 100,
125
- layout: 'horizontal', alignItems: 'center',
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(parent, (parent as PenNode & { children: PenNode[] }).children)
129
- expect(result[0].y).toBe(35) // (100 - 30) / 2
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, height: 100,
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(parent, (parent as PenNode & { children: PenNode[] }).children)
139
- expect(result).toHaveLength(1)
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, height: 100,
226
+ width: 300,
227
+ height: 100,
145
228
  layout: 'none',
146
229
  children: [rect('a', 50, 30)],
147
- })
148
- const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
149
- expect(result).toHaveLength(1)
150
- expect(result[0].id).toBe('a')
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
+ });