@zseven-w/pen-core 0.6.0 → 0.7.1

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -25
  3. package/package.json +19 -5
  4. package/src/__tests__/arc-path.test.ts +23 -23
  5. package/src/__tests__/codegen-utils.test.ts +50 -0
  6. package/src/__tests__/design-md-parser.test.ts +49 -0
  7. package/src/__tests__/font-utils.test.ts +15 -15
  8. package/src/__tests__/layout-engine.test.ts +169 -83
  9. package/src/__tests__/merge-helpers.test.ts +143 -0
  10. package/src/__tests__/node-diff.test.ts +139 -0
  11. package/src/__tests__/node-helpers.test.ts +19 -19
  12. package/src/__tests__/node-merge.test.ts +425 -0
  13. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  14. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  15. package/src/__tests__/normalize.test.ts +119 -80
  16. package/src/__tests__/path-anchors.test.ts +98 -0
  17. package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
  18. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  19. package/src/__tests__/text-measure.test.ts +84 -79
  20. package/src/__tests__/tree-utils.test.ts +133 -102
  21. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  22. package/src/__tests__/variables.test.ts +68 -65
  23. package/src/arc-path.ts +35 -35
  24. package/src/boolean-ops.ts +131 -142
  25. package/src/constants.ts +36 -36
  26. package/src/design-md-parser.ts +363 -0
  27. package/src/font-utils.ts +30 -15
  28. package/src/id.ts +1 -1
  29. package/src/index.ts +47 -13
  30. package/src/layout/engine.ts +255 -224
  31. package/src/layout/normalize-tree.ts +140 -0
  32. package/src/layout/strip-redundant-section-fills.ts +155 -0
  33. package/src/layout/text-measure.ts +130 -106
  34. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  35. package/src/merge/index.ts +16 -0
  36. package/src/merge/merge-helpers.ts +113 -0
  37. package/src/merge/node-diff.ts +123 -0
  38. package/src/merge/node-merge.ts +651 -0
  39. package/src/node-helpers.ts +18 -5
  40. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  41. package/src/normalize.ts +75 -81
  42. package/src/path-anchors.ts +331 -0
  43. package/src/sync-lock.ts +3 -3
  44. package/src/tree-utils.ts +180 -158
  45. package/src/variables/replace-refs.ts +63 -60
  46. package/src/variables/resolve.ts +98 -106
@@ -0,0 +1,322 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import { unwrapFakePhoneMockups } from '../layout/unwrap-fake-phone-mockup';
4
+
5
+ const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
6
+ ({
7
+ id: 'f1',
8
+ type: 'frame',
9
+ ...props,
10
+ }) as PenNode;
11
+
12
+ const rect = (id: string, props: Partial<PenNode> = {}): PenNode =>
13
+ ({
14
+ id,
15
+ type: 'rectangle',
16
+ width: 50,
17
+ height: 30,
18
+ ...props,
19
+ }) as PenNode;
20
+
21
+ describe('unwrapFakePhoneMockups', () => {
22
+ it('leaves a real phone mockup with 0 children alone', () => {
23
+ const phone = frame({
24
+ id: 'phone',
25
+ name: 'Phone Mockup',
26
+ cornerRadius: 32,
27
+ width: 280,
28
+ height: 580,
29
+ children: [],
30
+ });
31
+ const root = frame({ id: 'root', children: [phone] });
32
+ unwrapFakePhoneMockups(root);
33
+ const kids = (root as PenNode & { children: PenNode[] }).children;
34
+ expect(kids).toHaveLength(1);
35
+ expect(kids[0].id).toBe('phone');
36
+ });
37
+
38
+ it('leaves a real phone mockup with a single placeholder child alone', () => {
39
+ const placeholder: PenNode = {
40
+ id: 'placeholder',
41
+ type: 'text',
42
+ content: 'App Preview',
43
+ } as PenNode;
44
+ const phone = frame({
45
+ id: 'phone',
46
+ name: 'Phone Mockup',
47
+ cornerRadius: 32,
48
+ width: 280,
49
+ children: [placeholder],
50
+ });
51
+ const root = frame({ id: 'root', children: [phone] });
52
+ unwrapFakePhoneMockups(root);
53
+ const kids = (root as PenNode & { children: PenNode[] }).children;
54
+ expect(kids).toHaveLength(1);
55
+ expect(kids[0].id).toBe('phone');
56
+ });
57
+
58
+ it('unwraps a fake "Phone Mockup" frame with multiple children', () => {
59
+ const fakePhone = frame({
60
+ id: 'fake-phone',
61
+ name: 'Phone Mockup',
62
+ cornerRadius: 32,
63
+ width: 260,
64
+ height: 456,
65
+ layout: 'horizontal',
66
+ children: [rect('section1'), rect('section2'), rect('section3')],
67
+ });
68
+ const root = frame({
69
+ id: 'root',
70
+ children: [rect('header'), fakePhone],
71
+ });
72
+ unwrapFakePhoneMockups(root);
73
+ const kids = (root as PenNode & { children: PenNode[] }).children;
74
+ expect(kids).toHaveLength(4);
75
+ expect(kids[0].id).toBe('header');
76
+ expect(kids[1].id).toBe('section1');
77
+ expect(kids[2].id).toBe('section2');
78
+ expect(kids[3].id).toBe('section3');
79
+ expect(kids.find((c) => c.id === 'fake-phone')).toBeUndefined();
80
+ });
81
+
82
+ it('detects fake mockup by visual signature even when name is generic', () => {
83
+ const fakePhone = frame({
84
+ id: 'wrapper',
85
+ name: 'Container',
86
+ cornerRadius: 32,
87
+ width: 280,
88
+ layout: 'vertical',
89
+ children: [rect('a'), rect('b'), rect('c'), rect('d')],
90
+ });
91
+ const root = frame({ id: 'root', children: [fakePhone] });
92
+ unwrapFakePhoneMockups(root);
93
+ const kids = (root as PenNode & { children: PenNode[] }).children;
94
+ expect(kids).toHaveLength(4);
95
+ expect(kids.map((c) => c.id)).toEqual(['a', 'b', 'c', 'd']);
96
+ });
97
+
98
+ it('does not unwrap a card-like frame with cornerRadius < phone bezel range', () => {
99
+ // Cards typically use cornerRadius 12-20, not 28-40.
100
+ const card = frame({
101
+ id: 'card',
102
+ name: 'Stat Card',
103
+ cornerRadius: 16,
104
+ width: 200,
105
+ layout: 'vertical',
106
+ children: [rect('icon'), rect('label'), rect('value')],
107
+ });
108
+ const root = frame({ id: 'root', children: [card] });
109
+ unwrapFakePhoneMockups(root);
110
+ const kids = (root as PenNode & { children: PenNode[] }).children;
111
+ expect(kids).toHaveLength(1);
112
+ expect(kids[0].id).toBe('card');
113
+ });
114
+
115
+ it('does not unwrap a wide frame even with cornerRadius 32', () => {
116
+ // A 600px wide hero card with cornerRadius 32 is not a phone mockup.
117
+ const heroCard = frame({
118
+ id: 'hero',
119
+ name: 'Hero Card',
120
+ cornerRadius: 32,
121
+ width: 600,
122
+ layout: 'vertical',
123
+ children: [rect('a'), rect('b'), rect('c')],
124
+ });
125
+ const root = frame({ id: 'root', children: [heroCard] });
126
+ unwrapFakePhoneMockups(root);
127
+ const kids = (root as PenNode & { children: PenNode[] }).children;
128
+ expect(kids).toHaveLength(1);
129
+ });
130
+
131
+ it('recurses into nested frames', () => {
132
+ const fakePhone = frame({
133
+ id: 'fake',
134
+ name: 'Phone Mockup',
135
+ cornerRadius: 32,
136
+ width: 260,
137
+ layout: 'horizontal',
138
+ children: [rect('a'), rect('b')],
139
+ });
140
+ const inner = frame({ id: 'inner', children: [fakePhone] });
141
+ const outer = frame({ id: 'outer', children: [inner] });
142
+ unwrapFakePhoneMockups(outer);
143
+ const innerKids = (inner as PenNode & { children: PenNode[] }).children;
144
+ expect(innerKids).toHaveLength(2);
145
+ expect(innerKids[0].id).toBe('a');
146
+ expect(innerKids[1].id).toBe('b');
147
+ });
148
+
149
+ it('does nothing for non-frame leaf nodes', () => {
150
+ const text: PenNode = { id: 't', type: 'text', content: 'hi' } as PenNode;
151
+ expect(() => unwrapFakePhoneMockups(text)).not.toThrow();
152
+ });
153
+
154
+ it('returns false when no fake phone mockup is found anywhere', () => {
155
+ const root = frame({
156
+ id: 'root',
157
+ layout: 'vertical',
158
+ children: [rect('a'), rect('b'), rect('c')],
159
+ });
160
+ const changed = unwrapFakePhoneMockups(root);
161
+ expect(changed).toBe(false);
162
+ });
163
+
164
+ it('returns true when a child fake phone mockup is unwrapped', () => {
165
+ const fakePhone = frame({
166
+ id: 'fake',
167
+ name: 'Phone Mockup',
168
+ cornerRadius: 32,
169
+ width: 260,
170
+ layout: 'horizontal',
171
+ children: [rect('a'), rect('b')],
172
+ });
173
+ const root = frame({ id: 'root', children: [fakePhone] });
174
+ const changed = unwrapFakePhoneMockups(root);
175
+ expect(changed).toBe(true);
176
+ });
177
+
178
+ it('returns true when the root itself is sanitized', () => {
179
+ const fakePhoneRoot = frame({
180
+ id: 'phone',
181
+ name: 'Phone Mockup',
182
+ cornerRadius: 32,
183
+ width: 260,
184
+ layout: 'horizontal',
185
+ children: [rect('a'), rect('b')],
186
+ });
187
+ const changed = unwrapFakePhoneMockups(fakePhoneRoot);
188
+ expect(changed).toBe(true);
189
+ });
190
+
191
+ it('returns true when a deeply nested fake phone mockup is unwrapped', () => {
192
+ const fake = frame({
193
+ id: 'fake',
194
+ name: 'Phone Mockup',
195
+ cornerRadius: 32,
196
+ width: 260,
197
+ layout: 'horizontal',
198
+ children: [rect('a')],
199
+ });
200
+ const inner = frame({ id: 'inner', children: [fake] });
201
+ const outer = frame({ id: 'outer', children: [inner] });
202
+ const changed = unwrapFakePhoneMockups(outer);
203
+ expect(changed).toBe(true);
204
+ });
205
+
206
+ it('returns false for a real phone mockup with at most one child', () => {
207
+ const realPhone = frame({
208
+ id: 'phone',
209
+ name: 'Phone Mockup',
210
+ cornerRadius: 32,
211
+ width: 280,
212
+ height: 580,
213
+ children: [],
214
+ });
215
+ const root = frame({ id: 'root', children: [realPhone] });
216
+ const changed = unwrapFakePhoneMockups(root);
217
+ expect(changed).toBe(false);
218
+ });
219
+
220
+ it('sanitizes the root node when the root itself is a fake phone mockup', () => {
221
+ // The actual sub-agent path: bottomNav-root IS the fake wrapper. The
222
+ // function is called on the wrapper itself, with no parent in scope.
223
+ // We can't drop the root, so we sanitize: strip phone bezel visuals,
224
+ // restore vertical layout + fill_container width, drop the misleading
225
+ // name. The children stay (might be duplicates, but at least they're
226
+ // not crushed into a 260px column).
227
+ const fakePhoneRoot = frame({
228
+ id: 'bottomNav-root',
229
+ name: 'Phone Mockup',
230
+ width: 260,
231
+ height: 456,
232
+ cornerRadius: 32,
233
+ fill: [{ type: 'solid', color: '#0A0A0A' }],
234
+ layout: 'horizontal',
235
+ children: [rect('child1'), rect('child2'), rect('child3')],
236
+ });
237
+ unwrapFakePhoneMockups(fakePhoneRoot);
238
+ const sanitized = fakePhoneRoot as PenNode & {
239
+ width?: unknown;
240
+ cornerRadius?: unknown;
241
+ layout?: unknown;
242
+ name?: unknown;
243
+ };
244
+ // Phone mockup visual signature must be gone
245
+ expect(sanitized.cornerRadius).toBeUndefined();
246
+ expect(sanitized.width).toBe('fill_container');
247
+ expect(sanitized.layout).toBe('vertical');
248
+ // Misleading name is cleared
249
+ expect(sanitized.name).not.toBe('Phone Mockup');
250
+ // Children survive — at least they will render in a normal vertical stack
251
+ const kids = (fakePhoneRoot as PenNode & { children: PenNode[] }).children;
252
+ expect(kids).toHaveLength(3);
253
+ expect(kids[0].id).toBe('child1');
254
+ });
255
+
256
+ it('does not sanitize a real phone mockup root with 0 children', () => {
257
+ // Important: when called on a legitimate phone mockup root (no children),
258
+ // it must be left alone — the sanitize branch should only fire when the
259
+ // mockup is structurally invalid (children > 1 OR layout horizontal/vertical).
260
+ const realPhoneRoot = frame({
261
+ id: 'phone',
262
+ name: 'Phone Mockup',
263
+ cornerRadius: 32,
264
+ width: 280,
265
+ height: 580,
266
+ children: [],
267
+ });
268
+ unwrapFakePhoneMockups(realPhoneRoot);
269
+ const node = realPhoneRoot as PenNode & {
270
+ cornerRadius?: unknown;
271
+ width?: unknown;
272
+ name?: unknown;
273
+ };
274
+ expect(node.cornerRadius).toBe(32);
275
+ expect(node.width).toBe(280);
276
+ expect(node.name).toBe('Phone Mockup');
277
+ });
278
+
279
+ it('reproduces the M2.7 health-tracker bottomNav case', () => {
280
+ // Direct repro of the actual failing case: bottomNav-root frame named
281
+ // "Phone Mockup", w=260, h=456, cornerRadius=32, layout=horizontal,
282
+ // wrapping a complete copy of the entire mobile app (6 sections).
283
+ const fakeBottomNav = frame({
284
+ id: 'bottomNav-root',
285
+ name: 'Phone Mockup',
286
+ width: 260,
287
+ height: 456,
288
+ cornerRadius: 32,
289
+ fill: [{ type: 'solid', color: '#0A0A0A' }],
290
+ layout: 'horizontal',
291
+ children: [
292
+ frame({ id: 'bottomNav-header', name: 'Header' }),
293
+ frame({ id: 'bottomNav-activity-section', name: 'Activity Section' }),
294
+ frame({ id: 'bottomNav-heart-card', name: 'Heart Rate Card' }),
295
+ frame({ id: 'bottomNav-weekly-section', name: 'Weekly Summary' }),
296
+ frame({ id: 'bottomNav-upcoming-section', name: 'Upcoming Section' }),
297
+ frame({ id: 'bottomNav-tab-bar', name: 'Tab Bar' }),
298
+ ],
299
+ });
300
+ const rootFrame = frame({
301
+ id: 'root-frame',
302
+ name: 'Health Tracker Home',
303
+ layout: 'vertical',
304
+ children: [
305
+ frame({ id: 'header-root', name: 'Header Section' }),
306
+ frame({ id: 'activityRings-root', name: 'Activity Rings Section' }),
307
+ frame({ id: 'heartRate-root', name: 'HeartRateCard' }),
308
+ frame({ id: 'weeklyWorkout-root', name: 'Weekly Workout Summary' }),
309
+ frame({ id: 'upcomingWorkouts-root', name: 'Upcoming Workouts Section' }),
310
+ fakeBottomNav,
311
+ ],
312
+ });
313
+ unwrapFakePhoneMockups(rootFrame);
314
+ const kids = (rootFrame as PenNode & { children: PenNode[] }).children;
315
+ // Original 5 real sections + 6 unwrapped grandchildren = 11
316
+ expect(kids).toHaveLength(11);
317
+ // Fake wrapper is gone
318
+ expect(kids.find((c) => c.id === 'bottomNav-root')).toBeUndefined();
319
+ // Real tab-bar is now a direct child of root-frame
320
+ expect(kids.find((c) => c.id === 'bottomNav-tab-bar')).toBeDefined();
321
+ });
322
+ });
@@ -1,6 +1,6 @@
1
- import { describe, it, expect } from 'vitest'
2
- import type { PenNode } from '@zseven-w/pen-types'
3
- import type { VariableDefinition } from '@zseven-w/pen-types'
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import type { VariableDefinition } from '@zseven-w/pen-types';
4
4
  import {
5
5
  isVariableRef,
6
6
  getDefaultTheme,
@@ -8,11 +8,11 @@ import {
8
8
  resolveColorRef,
9
9
  resolveNumericRef,
10
10
  resolveNodeForCanvas,
11
- } from '../variables/resolve'
11
+ } from '../variables/resolve';
12
12
 
13
13
  const vars: Record<string, VariableDefinition> = {
14
- 'primary': { type: 'color', value: '#3b82f6' },
15
- 'spacing': { type: 'number', value: 16 },
14
+ primary: { type: 'color', value: '#3b82f6' },
15
+ spacing: { type: 'number', value: 16 },
16
16
  'themed-color': {
17
17
  type: 'color',
18
18
  value: [
@@ -20,113 +20,116 @@ const vars: Record<string, VariableDefinition> = {
20
20
  { value: '#1a1a1a', theme: { 'Theme-1': 'Dark' } },
21
21
  ],
22
22
  },
23
- }
23
+ };
24
24
 
25
25
  describe('variables/resolve', () => {
26
26
  describe('isVariableRef', () => {
27
27
  it('returns true for $ prefixed strings', () => {
28
- expect(isVariableRef('$primary')).toBe(true)
29
- })
28
+ expect(isVariableRef('$primary')).toBe(true);
29
+ });
30
30
 
31
31
  it('returns false for regular strings', () => {
32
- expect(isVariableRef('#ff0000')).toBe(false)
33
- })
32
+ expect(isVariableRef('#ff0000')).toBe(false);
33
+ });
34
34
 
35
35
  it('returns false for non-strings', () => {
36
- expect(isVariableRef(42)).toBe(false)
37
- expect(isVariableRef(undefined)).toBe(false)
38
- })
39
- })
36
+ expect(isVariableRef(42)).toBe(false);
37
+ expect(isVariableRef(undefined)).toBe(false);
38
+ });
39
+ });
40
40
 
41
41
  describe('getDefaultTheme', () => {
42
42
  it('returns first value of each theme axis', () => {
43
- const themes = { 'Theme-1': ['Light', 'Dark'], 'Size': ['Compact', 'Regular'] }
43
+ const themes = { 'Theme-1': ['Light', 'Dark'], Size: ['Compact', 'Regular'] };
44
44
  expect(getDefaultTheme(themes)).toEqual({
45
45
  'Theme-1': 'Light',
46
- 'Size': 'Compact',
47
- })
48
- })
46
+ Size: 'Compact',
47
+ });
48
+ });
49
49
 
50
50
  it('returns empty for undefined themes', () => {
51
- expect(getDefaultTheme(undefined)).toEqual({})
52
- })
53
- })
51
+ expect(getDefaultTheme(undefined)).toEqual({});
52
+ });
53
+ });
54
54
 
55
55
  describe('resolveVariableRef', () => {
56
56
  it('resolves a simple color variable', () => {
57
- expect(resolveVariableRef('$primary', vars)).toBe('#3b82f6')
58
- })
57
+ expect(resolveVariableRef('$primary', vars)).toBe('#3b82f6');
58
+ });
59
59
 
60
60
  it('resolves a number variable', () => {
61
- expect(resolveVariableRef('$spacing', vars)).toBe(16)
62
- })
61
+ expect(resolveVariableRef('$spacing', vars)).toBe(16);
62
+ });
63
63
 
64
64
  it('returns undefined for missing variable', () => {
65
- expect(resolveVariableRef('$missing', vars)).toBeUndefined()
66
- })
65
+ expect(resolveVariableRef('$missing', vars)).toBeUndefined();
66
+ });
67
67
 
68
68
  it('resolves themed values with matching theme', () => {
69
- expect(resolveVariableRef('$themed-color', vars, { 'Theme-1': 'Dark' })).toBe('#1a1a1a')
70
- })
69
+ expect(resolveVariableRef('$themed-color', vars, { 'Theme-1': 'Dark' })).toBe('#1a1a1a');
70
+ });
71
71
 
72
72
  it('falls back to first value when no theme match', () => {
73
- expect(resolveVariableRef('$themed-color', vars)).toBe('#ffffff')
74
- })
73
+ expect(resolveVariableRef('$themed-color', vars)).toBe('#ffffff');
74
+ });
75
75
 
76
76
  it('returns undefined for non-ref strings', () => {
77
- expect(resolveVariableRef('#ff0000', vars)).toBeUndefined()
78
- })
79
- })
77
+ expect(resolveVariableRef('#ff0000', vars)).toBeUndefined();
78
+ });
79
+ });
80
80
 
81
81
  describe('resolveColorRef', () => {
82
82
  it('returns the color when not a ref', () => {
83
- expect(resolveColorRef('#ff0000', vars)).toBe('#ff0000')
84
- })
83
+ expect(resolveColorRef('#ff0000', vars)).toBe('#ff0000');
84
+ });
85
85
 
86
86
  it('resolves a color ref', () => {
87
- expect(resolveColorRef('$primary', vars)).toBe('#3b82f6')
88
- })
87
+ expect(resolveColorRef('$primary', vars)).toBe('#3b82f6');
88
+ });
89
89
 
90
90
  it('returns undefined for undefined input', () => {
91
- expect(resolveColorRef(undefined, vars)).toBeUndefined()
92
- })
93
- })
91
+ expect(resolveColorRef(undefined, vars)).toBeUndefined();
92
+ });
93
+ });
94
94
 
95
95
  describe('resolveNumericRef', () => {
96
96
  it('returns the number when not a ref', () => {
97
- expect(resolveNumericRef(42, vars)).toBe(42)
98
- })
97
+ expect(resolveNumericRef(42, vars)).toBe(42);
98
+ });
99
99
 
100
100
  it('resolves a numeric ref', () => {
101
- expect(resolveNumericRef('$spacing', vars)).toBe(16)
102
- })
101
+ expect(resolveNumericRef('$spacing', vars)).toBe(16);
102
+ });
103
103
 
104
104
  it('returns undefined for non-numeric results', () => {
105
- expect(resolveNumericRef('$primary', vars)).toBeUndefined()
106
- })
107
- })
105
+ expect(resolveNumericRef('$primary', vars)).toBeUndefined();
106
+ });
107
+ });
108
108
 
109
109
  describe('resolveNodeForCanvas', () => {
110
110
  it('returns same node when no variables', () => {
111
- const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0 }
112
- expect(resolveNodeForCanvas(node, {})).toBe(node)
113
- })
111
+ const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0 };
112
+ expect(resolveNodeForCanvas(node, {})).toBe(node);
113
+ });
114
114
 
115
115
  it('resolves $variable opacity', () => {
116
- const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0, opacity: '$spacing' }
117
- const resolved = resolveNodeForCanvas(node, vars)
118
- expect(resolved.opacity).toBe(16)
119
- expect(resolved).not.toBe(node)
120
- })
116
+ const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0, opacity: '$spacing' };
117
+ const resolved = resolveNodeForCanvas(node, vars);
118
+ expect(resolved.opacity).toBe(16);
119
+ expect(resolved).not.toBe(node);
120
+ });
121
121
 
122
122
  it('resolves fill colors', () => {
123
123
  const node: PenNode = {
124
- id: '1', type: 'rectangle', x: 0, y: 0,
124
+ id: '1',
125
+ type: 'rectangle',
126
+ x: 0,
127
+ y: 0,
125
128
  fill: [{ type: 'solid', color: '$primary' }],
126
- }
127
- const resolved = resolveNodeForCanvas(node, vars)
128
- const fill = (resolved as { fill: Array<{ color: string }> }).fill
129
- expect(fill[0].color).toBe('#3b82f6')
130
- })
131
- })
132
- })
129
+ };
130
+ const resolved = resolveNodeForCanvas(node, vars);
131
+ const fill = (resolved as { fill: Array<{ color: string }> }).fill;
132
+ expect(fill[0].color).toBe('#3b82f6');
133
+ });
134
+ });
135
+ });
package/src/arc-path.ts CHANGED
@@ -14,28 +14,28 @@ export function buildEllipseArcPath(
14
14
  sweepDeg: number,
15
15
  inner: number,
16
16
  ): string {
17
- const startRad = (startDeg * Math.PI) / 180
18
- const sweepRad = (sweepDeg * Math.PI) / 180
19
- const endRad = startRad + sweepRad
17
+ const startRad = (startDeg * Math.PI) / 180;
18
+ const sweepRad = (sweepDeg * Math.PI) / 180;
19
+ const endRad = startRad + sweepRad;
20
20
 
21
- const rx = w / 2
22
- const ry = h / 2
23
- const cx = rx
24
- const cy = ry
21
+ const rx = w / 2;
22
+ const ry = h / 2;
23
+ const cx = rx;
24
+ const cy = ry;
25
25
 
26
26
  // Outer arc endpoints
27
- const ox1 = cx + rx * Math.cos(startRad)
28
- const oy1 = cy + ry * Math.sin(startRad)
29
- const ox2 = cx + rx * Math.cos(endRad)
30
- const oy2 = cy + ry * Math.sin(endRad)
27
+ const ox1 = cx + rx * Math.cos(startRad);
28
+ const oy1 = cy + ry * Math.sin(startRad);
29
+ const ox2 = cx + rx * Math.cos(endRad);
30
+ const oy2 = cy + ry * Math.sin(endRad);
31
31
 
32
- const large = sweepRad > Math.PI ? 1 : 0
32
+ const large = sweepRad > Math.PI ? 1 : 0;
33
33
 
34
34
  // Near-full circle (>=~359.9°): split into two semicircular arcs
35
35
  if (sweepRad > Math.PI * 2 - 0.02) {
36
- const midRad = startRad + Math.PI
37
- const omx = cx + rx * Math.cos(midRad)
38
- const omy = cy + ry * Math.sin(midRad)
36
+ const midRad = startRad + Math.PI;
37
+ const omx = cx + rx * Math.cos(midRad);
38
+ const omy = cy + ry * Math.sin(midRad);
39
39
 
40
40
  if (inner <= 0.001) {
41
41
  return [
@@ -43,15 +43,15 @@ export function buildEllipseArcPath(
43
43
  `A${f(rx)} ${f(ry)} 0 1 1 ${f(omx)} ${f(omy)}`,
44
44
  `A${f(rx)} ${f(ry)} 0 1 1 ${f(ox1)} ${f(oy1)}`,
45
45
  'Z',
46
- ].join(' ')
46
+ ].join(' ');
47
47
  }
48
48
 
49
- const irx = rx * inner
50
- const iry = ry * inner
51
- const ix1 = cx + irx * Math.cos(startRad)
52
- const iy1 = cy + iry * Math.sin(startRad)
53
- const imx = cx + irx * Math.cos(midRad)
54
- const imy = cy + iry * Math.sin(midRad)
49
+ const irx = rx * inner;
50
+ const iry = ry * inner;
51
+ const ix1 = cx + irx * Math.cos(startRad);
52
+ const iy1 = cy + iry * Math.sin(startRad);
53
+ const imx = cx + irx * Math.cos(midRad);
54
+ const imy = cy + iry * Math.sin(midRad);
55
55
  return [
56
56
  `M${f(ox1)} ${f(oy1)}`,
57
57
  `A${f(rx)} ${f(ry)} 0 1 1 ${f(omx)} ${f(omy)}`,
@@ -60,28 +60,28 @@ export function buildEllipseArcPath(
60
60
  `A${f(irx)} ${f(iry)} 0 1 0 ${f(imx)} ${f(imy)}`,
61
61
  `A${f(irx)} ${f(iry)} 0 1 0 ${f(ix1)} ${f(iy1)}`,
62
62
  'Z',
63
- ].join(' ')
63
+ ].join(' ');
64
64
  }
65
65
 
66
66
  if (inner <= 0.001) {
67
67
  // Pie slice: center → outer start → arc → close
68
- return `M${f(cx)} ${f(cy)} L${f(ox1)} ${f(oy1)} A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)} Z`
68
+ return `M${f(cx)} ${f(cy)} L${f(ox1)} ${f(oy1)} A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)} Z`;
69
69
  }
70
70
 
71
71
  // Donut slice: outer arc → line to inner → inner arc (reversed) → close
72
- const irx = rx * inner
73
- const iry = ry * inner
74
- const ix1 = cx + irx * Math.cos(startRad)
75
- const iy1 = cy + iry * Math.sin(startRad)
76
- const ix2 = cx + irx * Math.cos(endRad)
77
- const iy2 = cy + iry * Math.sin(endRad)
72
+ const irx = rx * inner;
73
+ const iry = ry * inner;
74
+ const ix1 = cx + irx * Math.cos(startRad);
75
+ const iy1 = cy + iry * Math.sin(startRad);
76
+ const ix2 = cx + irx * Math.cos(endRad);
77
+ const iy2 = cy + iry * Math.sin(endRad);
78
78
  return [
79
79
  `M${f(ox1)} ${f(oy1)}`,
80
80
  `A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)}`,
81
81
  `L${f(ix2)} ${f(iy2)}`,
82
82
  `A${f(irx)} ${f(iry)} 0 ${large} 0 ${f(ix1)} ${f(iy1)}`,
83
83
  'Z',
84
- ].join(' ')
84
+ ].join(' ');
85
85
  }
86
86
 
87
87
  /** True when the arc parameters describe something other than a plain full ellipse. */
@@ -90,11 +90,11 @@ export function isArcEllipse(
90
90
  sweepAngle?: number,
91
91
  innerRadius?: number,
92
92
  ): boolean {
93
- const sweep = sweepAngle ?? 360
94
- const inner = innerRadius ?? 0
95
- return sweep < 359.9 || inner > 0.001
93
+ const sweep = sweepAngle ?? 360;
94
+ const inner = innerRadius ?? 0;
95
+ return sweep < 359.9 || inner > 0.001;
96
96
  }
97
97
 
98
98
  function f(n: number): string {
99
- return Math.abs(n) < 0.005 ? '0' : parseFloat(n.toFixed(2)).toString()
99
+ return Math.abs(n) < 0.005 ? '0' : parseFloat(n.toFixed(2)).toString();
100
100
  }