@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
@@ -1,118 +1,173 @@
1
- import { describe, it, expect } from 'vitest'
2
- import type { PenNode } from '@zseven-w/pen-types'
3
- import { resolveNodeForCanvas } from '../variables/resolve'
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import { resolveNodeForCanvas } from '../variables/resolve';
4
4
 
5
5
  describe('resolveNodeForCanvas — recursive', () => {
6
6
  const variables = {
7
7
  spacing: { name: 'spacing', type: 'number' as const, value: 16 },
8
8
  'bg-color': { name: 'bg-color', type: 'color' as const, value: '#ff0000' },
9
- }
9
+ };
10
10
 
11
11
  it('resolves $variable gap on nested child frame', () => {
12
12
  const doc: PenNode = {
13
- id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
13
+ id: 'root',
14
+ type: 'frame',
15
+ x: 0,
16
+ y: 0,
17
+ width: 400,
18
+ height: 600,
14
19
  layout: 'vertical',
15
20
  children: [
16
21
  {
17
- id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
18
- layout: 'vertical', gap: '$spacing' as unknown as number,
22
+ id: 'child',
23
+ type: 'frame',
24
+ x: 0,
25
+ y: 0,
26
+ width: 300,
27
+ height: 200,
28
+ layout: 'vertical',
29
+ gap: '$spacing' as unknown as number,
19
30
  children: [
20
31
  { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
21
32
  { id: 'text2', type: 'text', x: 0, y: 0, content: 'World' },
22
33
  ],
23
34
  } as PenNode,
24
35
  ],
25
- } as PenNode
36
+ } as PenNode;
26
37
 
27
- const result = resolveNodeForCanvas(doc, variables)
28
- const childFrame = (result as any).children[0]
29
- expect(childFrame.gap).toBe(16)
30
- })
38
+ const result = resolveNodeForCanvas(doc, variables);
39
+ const childFrame = (result as any).children[0];
40
+ expect(childFrame.gap).toBe(16);
41
+ });
31
42
 
32
43
  it('resolves $variable fill on deeply nested text node', () => {
33
44
  const doc: PenNode = {
34
- id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
45
+ id: 'root',
46
+ type: 'frame',
47
+ x: 0,
48
+ y: 0,
49
+ width: 400,
50
+ height: 600,
35
51
  children: [
36
52
  {
37
- id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
53
+ id: 'child',
54
+ type: 'frame',
55
+ x: 0,
56
+ y: 0,
57
+ width: 300,
58
+ height: 200,
38
59
  children: [
39
60
  {
40
- id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello',
61
+ id: 'text1',
62
+ type: 'text',
63
+ x: 0,
64
+ y: 0,
65
+ content: 'Hello',
41
66
  fill: [{ type: 'solid', color: '$bg-color' }],
42
67
  } as PenNode,
43
68
  ],
44
69
  } as PenNode,
45
70
  ],
46
- } as PenNode
71
+ } as PenNode;
47
72
 
48
- const result = resolveNodeForCanvas(doc, variables)
49
- const textNode = (result as any).children[0].children[0]
50
- expect(textNode.fill[0].color).toBe('#ff0000')
51
- })
73
+ const result = resolveNodeForCanvas(doc, variables);
74
+ const textNode = (result as any).children[0].children[0];
75
+ expect(textNode.fill[0].color).toBe('#ff0000');
76
+ });
52
77
 
53
78
  it('resolves $variable padding on nested frame', () => {
54
79
  const doc: PenNode = {
55
- id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
80
+ id: 'root',
81
+ type: 'frame',
82
+ x: 0,
83
+ y: 0,
84
+ width: 400,
85
+ height: 600,
56
86
  children: [
57
87
  {
58
- id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
88
+ id: 'child',
89
+ type: 'frame',
90
+ x: 0,
91
+ y: 0,
92
+ width: 300,
93
+ height: 200,
59
94
  padding: '$spacing' as unknown as number,
60
95
  children: [],
61
96
  } as PenNode,
62
97
  ],
63
- } as PenNode
98
+ } as PenNode;
64
99
 
65
- const result = resolveNodeForCanvas(doc, variables)
66
- const childFrame = (result as any).children[0]
67
- expect(childFrame.padding).toBe(16)
68
- })
100
+ const result = resolveNodeForCanvas(doc, variables);
101
+ const childFrame = (result as any).children[0];
102
+ expect(childFrame.padding).toBe(16);
103
+ });
69
104
 
70
105
  it('returns same reference when no variables exist', () => {
71
106
  const doc: PenNode = {
72
- id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
73
- children: [
74
- { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
75
- ],
76
- } as PenNode
107
+ id: 'root',
108
+ type: 'frame',
109
+ x: 0,
110
+ y: 0,
111
+ width: 400,
112
+ height: 600,
113
+ children: [{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' }],
114
+ } as PenNode;
77
115
 
78
- const result = resolveNodeForCanvas(doc, {})
79
- expect(result).toBe(doc)
80
- })
116
+ const result = resolveNodeForCanvas(doc, {});
117
+ expect(result).toBe(doc);
118
+ });
81
119
 
82
120
  it('resolves $variable opacity on nested text node', () => {
83
121
  const variables3 = {
84
122
  'text-opacity': { name: 'text-opacity', type: 'number' as const, value: 0.5 },
85
- }
123
+ };
86
124
  const doc: PenNode = {
87
- id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
125
+ id: 'root',
126
+ type: 'frame',
127
+ x: 0,
128
+ y: 0,
129
+ width: 400,
130
+ height: 600,
88
131
  children: [
89
132
  {
90
- id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
133
+ id: 'child',
134
+ type: 'frame',
135
+ x: 0,
136
+ y: 0,
137
+ width: 300,
138
+ height: 200,
91
139
  children: [
92
140
  {
93
- id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello',
141
+ id: 'text1',
142
+ type: 'text',
143
+ x: 0,
144
+ y: 0,
145
+ content: 'Hello',
94
146
  opacity: '$text-opacity' as unknown as number,
95
147
  } as PenNode,
96
148
  ],
97
149
  } as PenNode,
98
150
  ],
99
- } as PenNode
151
+ } as PenNode;
100
152
 
101
- const result = resolveNodeForCanvas(doc, variables3)
102
- const textNode = (result as any).children[0].children[0]
103
- expect(textNode.opacity).toBe(0.5)
104
- })
153
+ const result = resolveNodeForCanvas(doc, variables3);
154
+ const textNode = (result as any).children[0].children[0];
155
+ expect(textNode.opacity).toBe(0.5);
156
+ });
105
157
 
106
158
  it('preserves reference when children have no variables', () => {
107
- const variables2 = { color1: { name: 'color1', type: 'color' as const, value: '#000' } }
159
+ const variables2 = { color1: { name: 'color1', type: 'color' as const, value: '#000' } };
108
160
  const doc: PenNode = {
109
- id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
110
- children: [
111
- { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
112
- ],
113
- } as PenNode
161
+ id: 'root',
162
+ type: 'frame',
163
+ x: 0,
164
+ y: 0,
165
+ width: 400,
166
+ height: 600,
167
+ children: [{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' }],
168
+ } as PenNode;
114
169
 
115
- const result = resolveNodeForCanvas(doc, variables2)
116
- expect(result).toBe(doc)
117
- })
118
- })
170
+ const result = resolveNodeForCanvas(doc, variables2);
171
+ expect(result).toBe(doc);
172
+ });
173
+ });
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import { stripRedundantSectionFills } from '../layout/strip-redundant-section-fills';
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 solidFill = (color: string) => [{ type: 'solid' as const, color }];
13
+
14
+ describe('stripRedundantSectionFills', () => {
15
+ it('strips a section fill that exactly matches the root fill', () => {
16
+ const section = frame({
17
+ id: 'sec1',
18
+ name: 'Section',
19
+ fill: solidFill('#1a1a2e'),
20
+ children: [frame({ id: 'child' })],
21
+ });
22
+ const root = frame({
23
+ id: 'root',
24
+ fill: solidFill('#1a1a2e'),
25
+ children: [section],
26
+ });
27
+ const changed = stripRedundantSectionFills(root);
28
+ expect(changed).toBe(true);
29
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
30
+ });
31
+
32
+ it('strips a section fill that matches a common safe-dark tint', () => {
33
+ // Root has #1a1a2e (deep navy), section has #0A0A0A (near-black safe
34
+ // dark) — the classic M2.7 failure where the model picks a "safe"
35
+ // dark for every section root, hiding the intended root background.
36
+ const section = frame({
37
+ id: 'sec1',
38
+ name: 'Activity Rings Section',
39
+ fill: solidFill('#0A0A0A'),
40
+ children: [frame({ id: 'child' })],
41
+ });
42
+ const root = frame({
43
+ id: 'root',
44
+ fill: solidFill('#1a1a2e'),
45
+ children: [section],
46
+ });
47
+ stripRedundantSectionFills(root);
48
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
49
+ });
50
+
51
+ it('does not strip fill from a card (cards own their visual fill)', () => {
52
+ const card = frame({
53
+ id: 'card1',
54
+ name: 'Stat Card',
55
+ role: 'card',
56
+ fill: solidFill('#0A0A0A'),
57
+ cornerRadius: 12,
58
+ children: [frame({ id: 'child' })],
59
+ });
60
+ const root = frame({
61
+ id: 'root',
62
+ fill: solidFill('#1a1a2e'),
63
+ children: [card],
64
+ });
65
+ const changed = stripRedundantSectionFills(root);
66
+ expect(changed).toBe(false);
67
+ expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
68
+ });
69
+
70
+ it('does not strip fill from a button', () => {
71
+ const button = frame({
72
+ id: 'btn',
73
+ name: 'CTA Button',
74
+ role: 'button',
75
+ fill: solidFill('#0A0A0A'),
76
+ children: [frame({ id: 'label' })],
77
+ });
78
+ const root = frame({
79
+ id: 'root',
80
+ fill: solidFill('#1a1a2e'),
81
+ children: [button],
82
+ });
83
+ stripRedundantSectionFills(root);
84
+ expect((button as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
85
+ });
86
+
87
+ it('does not strip fill from a badge or chip', () => {
88
+ const badge = frame({
89
+ id: 'bd',
90
+ name: 'Badge',
91
+ role: 'badge',
92
+ fill: solidFill('#0A0A0A'),
93
+ });
94
+ const chip = frame({
95
+ id: 'ch',
96
+ name: 'Chip',
97
+ role: 'chip',
98
+ fill: solidFill('#0A0A0A'),
99
+ });
100
+ const root = frame({
101
+ id: 'root',
102
+ fill: solidFill('#1a1a2e'),
103
+ children: [badge, chip],
104
+ });
105
+ stripRedundantSectionFills(root);
106
+ expect((badge as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
107
+ expect((chip as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
108
+ });
109
+
110
+ it('does not strip a fill that is clearly distinct from root (intentional)', () => {
111
+ // #FF5733 is nothing like root's #1a1a2e and is not a safe-dark — it
112
+ // is probably a deliberate accent / hero section. Leave it.
113
+ const hero = frame({
114
+ id: 'hero',
115
+ name: 'Hero Section',
116
+ fill: solidFill('#FF5733'),
117
+ children: [frame({ id: 'headline' })],
118
+ });
119
+ const root = frame({
120
+ id: 'root',
121
+ fill: solidFill('#1a1a2e'),
122
+ children: [hero],
123
+ });
124
+ const changed = stripRedundantSectionFills(root);
125
+ expect(changed).toBe(false);
126
+ expect((hero as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#FF5733'));
127
+ });
128
+
129
+ it('strips fills from multiple sections in one pass', () => {
130
+ const section1 = frame({ id: 's1', fill: solidFill('#0A0A0A') });
131
+ const section2 = frame({ id: 's2', fill: solidFill('#0A0A0A') });
132
+ const section3 = frame({ id: 's3', fill: solidFill('#0A0A0A') });
133
+ const root = frame({
134
+ id: 'root',
135
+ fill: solidFill('#1a1a2e'),
136
+ children: [section1, section2, section3],
137
+ });
138
+ stripRedundantSectionFills(root);
139
+ expect((section1 as PenNode & { fill?: unknown }).fill).toBeUndefined();
140
+ expect((section2 as PenNode & { fill?: unknown }).fill).toBeUndefined();
141
+ expect((section3 as PenNode & { fill?: unknown }).fill).toBeUndefined();
142
+ });
143
+
144
+ it('does not touch deeply nested frames inside a section', () => {
145
+ // Only direct children of the root are considered "section level". A
146
+ // card nested three levels deep with the same color should be left
147
+ // alone — it is not a top-level section.
148
+ const deepCard = frame({
149
+ id: 'deep-card',
150
+ role: 'card',
151
+ fill: solidFill('#0A0A0A'),
152
+ });
153
+ const middle = frame({ id: 'middle', children: [deepCard] });
154
+ const section = frame({
155
+ id: 'section',
156
+ fill: solidFill('#0A0A0A'),
157
+ children: [middle],
158
+ });
159
+ const root = frame({
160
+ id: 'root',
161
+ fill: solidFill('#1a1a2e'),
162
+ children: [section],
163
+ });
164
+ stripRedundantSectionFills(root);
165
+ // Section (direct child) is stripped
166
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
167
+ // Deep card is left alone
168
+ expect((deepCard as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
169
+ });
170
+
171
+ it('returns false when there is nothing to strip', () => {
172
+ const root = frame({
173
+ id: 'root',
174
+ fill: solidFill('#1a1a2e'),
175
+ children: [
176
+ frame({ id: 's1' }), // no fill
177
+ frame({
178
+ id: 'card1',
179
+ role: 'card',
180
+ fill: solidFill('#0A0A0A'), // card protected
181
+ }),
182
+ ],
183
+ });
184
+ const changed = stripRedundantSectionFills(root);
185
+ expect(changed).toBe(false);
186
+ });
187
+
188
+ it('handles a root frame without a fill (treats only safe-dark sections)', () => {
189
+ // Root has no fill; we still strip sections that carry a safe-dark
190
+ // "default" fill, because those are almost certainly the sub-agent
191
+ // hedging against a missing background spec.
192
+ const section = frame({
193
+ id: 'sec',
194
+ fill: solidFill('#0A0A0A'),
195
+ });
196
+ const root = frame({
197
+ id: 'root',
198
+ children: [section],
199
+ });
200
+ stripRedundantSectionFills(root);
201
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
202
+ });
203
+
204
+ it('is strictly non-recursive: never touches grandchildren even when caller mis-targets a card', () => {
205
+ // Defensive: if a caller accidentally hands us a card frame instead of
206
+ // the page root, we must NOT recurse into it. Only the direct children
207
+ // of the passed node are ever considered — and a card header (no role,
208
+ // safe-dark fill) that is a DIRECT child of a card is still fair game,
209
+ // but anything deeper is untouched.
210
+ const deepInner = frame({
211
+ id: 'deep',
212
+ // no role, safe-dark — would normally be stripped, but is two levels
213
+ // down so must survive
214
+ fill: solidFill('#0A0A0A'),
215
+ });
216
+ const cardBody = frame({ id: 'body', children: [deepInner] });
217
+ const cardHeader = frame({
218
+ id: 'header',
219
+ // no role, safe-dark — direct child of the mis-targeted parent, so
220
+ // will still be stripped (the caller is at fault)
221
+ fill: solidFill('#0A0A0A'),
222
+ });
223
+ const card = frame({
224
+ id: 'card',
225
+ role: 'card',
226
+ fill: solidFill('#141414'),
227
+ children: [cardHeader, cardBody],
228
+ });
229
+ // Deliberately mis-target the card (not the page root). This must NOT
230
+ // crash and must NOT recurse into cardBody's grandchildren.
231
+ stripRedundantSectionFills(card);
232
+ // Card itself is untouched (we never touch the passed node)
233
+ expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#141414'));
234
+ // deepInner survives because strip is strictly non-recursive
235
+ expect((deepInner as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
236
+ });
237
+
238
+ it('reproduces the M2.7 health-tracker case', () => {
239
+ // Direct repro of the actual failure: root #1a1a2e, six section roots
240
+ // all hardcoded #0A0A0A, including one real card. The six section
241
+ // fills get stripped, the card keeps its fill.
242
+ const root = frame({
243
+ id: 'root-frame',
244
+ name: 'Health Dashboard',
245
+ fill: solidFill('#1a1a2e'),
246
+ children: [
247
+ frame({ id: 'header-root', name: 'Greeting Header', fill: solidFill('#0A0A0A') }),
248
+ frame({
249
+ id: 'activityRings-root',
250
+ name: 'Activity Rings Section',
251
+ fill: solidFill('#0A0A0A'),
252
+ }),
253
+ frame({
254
+ id: 'heartRate-root',
255
+ name: 'Heart Rate Card Section',
256
+ fill: solidFill('#0A0A0A'),
257
+ }),
258
+ frame({
259
+ id: 'workoutChart-root',
260
+ name: 'Weekly Workout Chart',
261
+ fill: solidFill('#0A0A0A'),
262
+ }),
263
+ frame({
264
+ id: 'upcomingWorkouts-root',
265
+ name: 'Upcoming Workouts',
266
+ fill: solidFill('#0A0A0A'),
267
+ }),
268
+ frame({ id: 'bottomNav-root', name: 'Bottom Tab Bar', fill: solidFill('#0A0A0A') }),
269
+ ],
270
+ });
271
+ const changed = stripRedundantSectionFills(root);
272
+ expect(changed).toBe(true);
273
+ const kids = (root as PenNode & { children: PenNode[] }).children;
274
+ for (const section of kids) {
275
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
276
+ }
277
+ });
278
+ });