@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.
- package/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -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 +109 -54
- 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 +131 -142
- 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 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- 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 +75 -81
- 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 +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',
|
|
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',
|
|
18
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
});
|