@zseven-w/pen-core 0.6.0 → 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 +109 -54
  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 +131 -142
  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 -224
  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 +130 -106
  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 +75 -81
  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 +98 -106
package/src/tree-utils.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { nanoid } from 'nanoid'
2
- import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@zseven-w/pen-types'
1
+ import { nanoid } from 'nanoid';
2
+ import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@zseven-w/pen-types';
3
3
 
4
- export const DEFAULT_FRAME_ID = 'root-frame'
5
- export const DEFAULT_PAGE_ID = 'page-1'
4
+ export const DEFAULT_FRAME_ID = 'root-frame';
5
+ export const DEFAULT_PAGE_ID = 'page-1';
6
6
 
7
7
  export function createEmptyDocument(): PenDocument {
8
8
  const children: PenNode[] = [
@@ -17,12 +17,12 @@ export function createEmptyDocument(): PenDocument {
17
17
  fill: [{ type: 'solid', color: '#FFFFFF' }],
18
18
  children: [],
19
19
  },
20
- ]
20
+ ];
21
21
  return {
22
22
  version: '1.0.0',
23
23
  pages: [{ id: DEFAULT_PAGE_ID, name: 'Page 1', children }],
24
24
  children: [],
25
- }
25
+ };
26
26
  }
27
27
 
28
28
  // ---------------------------------------------------------------------------
@@ -30,23 +30,17 @@ export function createEmptyDocument(): PenDocument {
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
32
  /** Get the active page object. */
33
- export function getActivePage(
34
- doc: PenDocument,
35
- activePageId: string | null,
36
- ): PenPage | undefined {
37
- if (!doc.pages || doc.pages.length === 0) return undefined
38
- if (!activePageId) return doc.pages[0]
39
- return doc.pages.find((p) => p.id === activePageId) ?? doc.pages[0]
33
+ export function getActivePage(doc: PenDocument, activePageId: string | null): PenPage | undefined {
34
+ if (!doc.pages || doc.pages.length === 0) return undefined;
35
+ if (!activePageId) return doc.pages[0];
36
+ return doc.pages.find((p) => p.id === activePageId) ?? doc.pages[0];
40
37
  }
41
38
 
42
39
  /** Get children for the active page (falls back to doc.children for legacy docs). */
43
- export function getActivePageChildren(
44
- doc: PenDocument,
45
- activePageId: string | null,
46
- ): PenNode[] {
47
- const page = getActivePage(doc, activePageId)
48
- if (page) return page.children
49
- return doc.children
40
+ export function getActivePageChildren(doc: PenDocument, activePageId: string | null): PenNode[] {
41
+ const page = getActivePage(doc, activePageId);
42
+ if (page) return page.children;
43
+ return doc.children;
50
44
  }
51
45
 
52
46
  /** Return a new document with the active page's children replaced. */
@@ -56,29 +50,27 @@ export function setActivePageChildren(
56
50
  children: PenNode[],
57
51
  ): PenDocument {
58
52
  if (doc.pages && doc.pages.length > 0) {
59
- const page = getActivePage(doc, activePageId)
60
- if (!page) return { ...doc, children }
53
+ const page = getActivePage(doc, activePageId);
54
+ if (!page) return { ...doc, children };
61
55
  return {
62
56
  ...doc,
63
- pages: doc.pages.map((p) =>
64
- p.id === page.id ? { ...p, children } : p,
65
- ),
66
- }
57
+ pages: doc.pages.map((p) => (p.id === page.id ? { ...p, children } : p)),
58
+ };
67
59
  }
68
- return { ...doc, children }
60
+ return { ...doc, children };
69
61
  }
70
62
 
71
63
  /** Get all children across all pages (for cross-page component resolution). */
72
64
  export function getAllChildren(doc: PenDocument): PenNode[] {
73
65
  if (doc.pages && doc.pages.length > 0) {
74
- return doc.pages.flatMap((p) => p.children)
66
+ return doc.pages.flatMap((p) => p.children);
75
67
  }
76
- return doc.children
68
+ return doc.children;
77
69
  }
78
70
 
79
71
  /** Migrate a legacy document (no pages) to page-based format. */
80
72
  export function migrateToPages(doc: PenDocument): PenDocument {
81
- if (doc.pages && doc.pages.length > 0) return doc
73
+ if (doc.pages && doc.pages.length > 0) return doc;
82
74
  return {
83
75
  ...doc,
84
76
  pages: [
@@ -89,17 +81,17 @@ export function migrateToPages(doc: PenDocument): PenDocument {
89
81
  },
90
82
  ],
91
83
  children: [],
92
- }
84
+ };
93
85
  }
94
86
 
95
87
  /** Recursively ensure all nodes in the tree have an `id`. */
96
88
  function ensureNodeIdsInTree(nodes: PenNode[]): void {
97
89
  for (const node of nodes) {
98
90
  if (!node.id) {
99
- ;(node as PenNodeBase).id = nanoid()
91
+ (node as PenNodeBase).id = nanoid();
100
92
  }
101
93
  if ('children' in node && node.children) {
102
- ensureNodeIdsInTree(node.children)
94
+ ensureNodeIdsInTree(node.children);
103
95
  }
104
96
  }
105
97
  }
@@ -108,42 +100,36 @@ function ensureNodeIdsInTree(nodes: PenNode[]): void {
108
100
  export function ensureDocumentNodeIds(doc: PenDocument): PenDocument {
109
101
  if (doc.pages) {
110
102
  for (const page of doc.pages) {
111
- if (!page.id) page.id = nanoid()
112
- ensureNodeIdsInTree(page.children)
103
+ if (!page.id) page.id = nanoid();
104
+ ensureNodeIdsInTree(page.children);
113
105
  }
114
106
  }
115
- ensureNodeIdsInTree(doc.children)
116
- return doc
107
+ ensureNodeIdsInTree(doc.children);
108
+ return doc;
117
109
  }
118
110
 
119
- export function findNodeInTree(
120
- nodes: PenNode[],
121
- id: string,
122
- ): PenNode | undefined {
111
+ export function findNodeInTree(nodes: PenNode[], id: string): PenNode | undefined {
123
112
  for (const node of nodes) {
124
- if (node.id === id) return node
113
+ if (node.id === id) return node;
125
114
  if ('children' in node && node.children) {
126
- const found = findNodeInTree(node.children, id)
127
- if (found) return found
115
+ const found = findNodeInTree(node.children, id);
116
+ if (found) return found;
128
117
  }
129
118
  }
130
- return undefined
119
+ return undefined;
131
120
  }
132
121
 
133
- export function findParentInTree(
134
- nodes: PenNode[],
135
- id: string,
136
- ): PenNode | undefined {
122
+ export function findParentInTree(nodes: PenNode[], id: string): PenNode | undefined {
137
123
  for (const node of nodes) {
138
124
  if ('children' in node && node.children) {
139
125
  for (const child of node.children) {
140
- if (child.id === id) return node
126
+ if (child.id === id) return node;
141
127
  }
142
- const found = findParentInTree(node.children, id)
143
- if (found) return found
128
+ const found = findParentInTree(node.children, id);
129
+ if (found) return found;
144
130
  }
145
131
  }
146
- return undefined
132
+ return undefined;
147
133
  }
148
134
 
149
135
  export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
@@ -151,10 +137,10 @@ export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
151
137
  .filter((n) => n.id !== id)
152
138
  .map((n) => {
153
139
  if ('children' in n && n.children) {
154
- return { ...n, children: removeNodeFromTree(n.children, id) }
140
+ return { ...n, children: removeNodeFromTree(n.children, id) };
155
141
  }
156
- return n
157
- })
142
+ return n;
143
+ });
158
144
  }
159
145
 
160
146
  export function updateNodeInTree(
@@ -162,29 +148,53 @@ export function updateNodeInTree(
162
148
  id: string,
163
149
  updates: Partial<PenNode>,
164
150
  ): PenNode[] {
165
- return nodes.map((n) => {
166
- if (n.id === id) {
167
- return { ...n, ...updates } as PenNode
151
+ if (Object.keys(updates).length === 0) return nodes;
152
+
153
+ const hasNodeChanges = (node: PenNode): boolean => {
154
+ const nodeRecord = node as unknown as Record<string, unknown>;
155
+ for (const [key, value] of Object.entries(updates)) {
156
+ if (!Object.is(nodeRecord[key], value)) {
157
+ return true;
158
+ }
168
159
  }
169
- if ('children' in n && n.children) {
170
- return {
171
- ...n,
172
- children: updateNodeInTree(n.children, id, updates),
173
- } as PenNode
160
+ return false;
161
+ };
162
+
163
+ for (let i = 0; i < nodes.length; i++) {
164
+ const node = nodes[i];
165
+
166
+ if (node.id === id) {
167
+ if (!hasNodeChanges(node)) return nodes;
168
+ const nextNodes = [...nodes];
169
+ nextNodes[i] = { ...node, ...updates } as PenNode;
170
+ return nextNodes;
174
171
  }
175
- return n
176
- })
172
+
173
+ if ('children' in node && node.children?.length) {
174
+ const nextChildren = updateNodeInTree(node.children, id, updates);
175
+ if (nextChildren !== node.children) {
176
+ const nextNodes = [...nodes];
177
+ nextNodes[i] = {
178
+ ...node,
179
+ children: nextChildren,
180
+ } as PenNode;
181
+ return nextNodes;
182
+ }
183
+ }
184
+ }
185
+
186
+ return nodes;
177
187
  }
178
188
 
179
189
  export function flattenNodes(nodes: PenNode[]): PenNode[] {
180
- const result: PenNode[] = []
190
+ const result: PenNode[] = [];
181
191
  for (const node of nodes) {
182
- result.push(node)
192
+ result.push(node);
183
193
  if ('children' in node && node.children) {
184
- result.push(...flattenNodes(node.children))
194
+ result.push(...flattenNodes(node.children));
185
195
  }
186
196
  }
187
- return result
197
+ return result;
188
198
  }
189
199
 
190
200
  export function insertNodeInTree(
@@ -194,47 +204,43 @@ export function insertNodeInTree(
194
204
  index?: number,
195
205
  ): PenNode[] {
196
206
  if (parentId === null) {
197
- const arr = [...nodes]
207
+ const arr = [...nodes];
198
208
  if (index !== undefined) {
199
- arr.splice(index, 0, node)
209
+ arr.splice(index, 0, node);
200
210
  } else {
201
- arr.push(node)
211
+ arr.push(node);
202
212
  }
203
- return arr
213
+ return arr;
204
214
  }
205
215
 
206
216
  return nodes.map((n) => {
207
217
  if (n.id === parentId) {
208
- const children = 'children' in n && n.children ? [...n.children] : []
218
+ const children = 'children' in n && n.children ? [...n.children] : [];
209
219
  if (index !== undefined) {
210
- children.splice(index, 0, node)
220
+ children.splice(index, 0, node);
211
221
  } else {
212
- children.push(node)
222
+ children.push(node);
213
223
  }
214
- return { ...n, children } as PenNode
224
+ return { ...n, children } as PenNode;
215
225
  }
216
226
  if ('children' in n && n.children) {
217
227
  return {
218
228
  ...n,
219
229
  children: insertNodeInTree(n.children, parentId, node, index),
220
- } as PenNode
230
+ } as PenNode;
221
231
  }
222
- return n
223
- })
232
+ return n;
233
+ });
224
234
  }
225
235
 
226
- export function isDescendantOf(
227
- nodes: PenNode[],
228
- nodeId: string,
229
- ancestorId: string,
230
- ): boolean {
231
- const ancestor = findNodeInTree(nodes, ancestorId)
232
- if (!ancestor || !('children' in ancestor) || !ancestor.children) return false
236
+ export function isDescendantOf(nodes: PenNode[], nodeId: string, ancestorId: string): boolean {
237
+ const ancestor = findNodeInTree(nodes, ancestorId);
238
+ if (!ancestor || !('children' in ancestor) || !ancestor.children) return false;
233
239
  for (const child of ancestor.children) {
234
- if (child.id === nodeId) return true
235
- if (isDescendantOf([child], nodeId, child.id)) return true
240
+ if (child.id === nodeId) return true;
241
+ if (isDescendantOf([child], nodeId, child.id)) return true;
236
242
  }
237
- return false
243
+ return false;
238
244
  }
239
245
 
240
246
  /** Resolve the bounding box of a node, falling back to its referenced component for RefNodes. */
@@ -242,18 +248,18 @@ export function getNodeBounds(
242
248
  node: PenNode,
243
249
  allNodes: PenNode[],
244
250
  ): { x: number; y: number; w: number; h: number } {
245
- const x = node.x ?? 0
246
- const y = node.y ?? 0
247
- let w = ('width' in node && typeof node.width === 'number') ? node.width : 0
248
- let h = ('height' in node && typeof node.height === 'number') ? node.height : 0
251
+ const x = node.x ?? 0;
252
+ const y = node.y ?? 0;
253
+ let w = 'width' in node && typeof node.width === 'number' ? node.width : 0;
254
+ let h = 'height' in node && typeof node.height === 'number' ? node.height : 0;
249
255
  if (node.type === 'ref' && !w) {
250
- const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
256
+ const refComp = findNodeInTree(allNodes, (node as RefNode).ref);
251
257
  if (refComp) {
252
- w = ('width' in refComp && typeof refComp.width === 'number') ? refComp.width : 100
253
- h = ('height' in refComp && typeof refComp.height === 'number') ? refComp.height : 100
258
+ w = 'width' in refComp && typeof refComp.width === 'number' ? refComp.width : 100;
259
+ h = 'height' in refComp && typeof refComp.height === 'number' ? refComp.height : 100;
254
260
  }
255
261
  }
256
- return { x, y, w: w || 100, h: h || 100 }
262
+ return { x, y, w: w || 100, h: h || 100 };
257
263
  }
258
264
 
259
265
  /**
@@ -270,36 +276,36 @@ export function findClearX(
270
276
  allNodes: PenNode[],
271
277
  gap = 20,
272
278
  ): number {
273
- const proposedW = sourceW
274
- let proposedX = sourceX + sourceW + gap
279
+ const proposedW = sourceW;
280
+ let proposedX = sourceX + sourceW + gap;
275
281
 
276
- const siblingBounds: { x: number; y: number; w: number; h: number }[] = []
282
+ const siblingBounds: { x: number; y: number; w: number; h: number }[] = [];
277
283
  for (const sib of siblings) {
278
- if (sib.id === excludeId) continue
279
- const b = getNodeBounds(sib, allNodes)
280
- if (b.w > 0 && b.h > 0) siblingBounds.push(b)
284
+ if (sib.id === excludeId) continue;
285
+ const b = getNodeBounds(sib, allNodes);
286
+ if (b.w > 0 && b.h > 0) siblingBounds.push(b);
281
287
  }
282
288
 
283
- let maxAttempts = 100
289
+ let maxAttempts = 100;
284
290
  while (maxAttempts-- > 0) {
285
291
  const hasOverlap = siblingBounds.some((b) => {
286
- const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
287
- const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
288
- return overlapX && overlapY
289
- })
290
- if (!hasOverlap) break
291
- let maxRight = proposedX
292
+ const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x;
293
+ const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y;
294
+ return overlapX && overlapY;
295
+ });
296
+ if (!hasOverlap) break;
297
+ let maxRight = proposedX;
292
298
  for (const b of siblingBounds) {
293
- const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
294
- const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
299
+ const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x;
300
+ const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y;
295
301
  if (overlapX && overlapY && b.x + b.w > maxRight) {
296
- maxRight = b.x + b.w
302
+ maxRight = b.x + b.w;
297
303
  }
298
304
  }
299
- proposedX = maxRight + gap
305
+ proposedX = maxRight + gap;
300
306
  }
301
307
 
302
- return proposedX
308
+ return proposedX;
303
309
  }
304
310
 
305
311
  /** Recursively scale all children's relative positions and sizes. */
@@ -309,20 +315,20 @@ export function scaleChildrenInPlace(
309
315
  scaleY: number,
310
316
  ): PenNode[] {
311
317
  return children.map((child) => {
312
- const updated: Record<string, unknown> = { ...child }
313
- if (child.x !== undefined) updated.x = child.x * scaleX
314
- if (child.y !== undefined) updated.y = child.y * scaleY
318
+ const updated: Record<string, unknown> = { ...child };
319
+ if (child.x !== undefined) updated.x = child.x * scaleX;
320
+ if (child.y !== undefined) updated.y = child.y * scaleY;
315
321
  if ('width' in child && typeof child.width === 'number') {
316
- updated.width = child.width * scaleX
322
+ updated.width = child.width * scaleX;
317
323
  }
318
324
  if ('height' in child && typeof child.height === 'number') {
319
- updated.height = child.height * scaleY
325
+ updated.height = child.height * scaleY;
320
326
  }
321
327
  if ('children' in child && child.children) {
322
- updated.children = scaleChildrenInPlace(child.children, scaleX, scaleY)
328
+ updated.children = scaleChildrenInPlace(child.children, scaleX, scaleY);
323
329
  }
324
- return updated as unknown as PenNode
325
- })
330
+ return updated as unknown as PenNode;
331
+ });
326
332
  }
327
333
 
328
334
  // ---------------------------------------------------------------------------
@@ -331,21 +337,16 @@ export function scaleChildrenInPlace(
331
337
 
332
338
  /** Deep-clone a node tree preserving all IDs. */
333
339
  export function deepCloneNode<T extends PenNode>(node: T): T {
334
- return structuredClone(node)
340
+ return structuredClone(node);
335
341
  }
336
342
 
337
343
  /** Clone a single node tree, assigning new IDs to every node. */
338
- export function cloneNodeWithNewIds(
339
- node: PenNode,
340
- idGenerator: () => string = nanoid,
341
- ): PenNode {
342
- const cloned = { ...node, id: idGenerator() } as PenNode
344
+ export function cloneNodeWithNewIds(node: PenNode, idGenerator: () => string = nanoid): PenNode {
345
+ const cloned = { ...node, id: idGenerator() } as PenNode;
343
346
  if ('children' in cloned && cloned.children) {
344
- cloned.children = cloned.children.map((c) =>
345
- cloneNodeWithNewIds(c, idGenerator),
346
- )
347
+ cloned.children = cloned.children.map((c) => cloneNodeWithNewIds(c, idGenerator));
347
348
  }
348
- return cloned
349
+ return cloned;
349
350
  }
350
351
 
351
352
  /** Clone multiple nodes with new IDs. Optionally strip `reusable` flag and apply position offset. */
@@ -353,38 +354,59 @@ export function cloneNodesWithNewIds(
353
354
  nodes: PenNode[],
354
355
  options: { offset?: number; stripReusable?: boolean; idGenerator?: () => string } = {},
355
356
  ): PenNode[] {
356
- const { offset = 0, stripReusable = true, idGenerator = nanoid } = options
357
+ const { offset = 0, stripReusable = true, idGenerator = nanoid } = options;
357
358
  return structuredClone(nodes).map((node) => {
358
- const withNewId = cloneNodeWithNewIds(node, idGenerator)
359
+ const withNewId = cloneNodeWithNewIds(node, idGenerator);
359
360
  if (stripReusable && 'reusable' in withNewId) {
360
- delete (withNewId as unknown as Record<string, unknown>).reusable
361
+ delete (withNewId as unknown as Record<string, unknown>).reusable;
361
362
  }
362
363
  if (offset !== 0) {
363
- withNewId.x = (withNewId.x ?? 0) + offset
364
- withNewId.y = (withNewId.y ?? 0) + offset
364
+ withNewId.x = (withNewId.x ?? 0) + offset;
365
+ withNewId.y = (withNewId.y ?? 0) + offset;
365
366
  }
366
- return withNewId
367
- })
367
+ return withNewId;
368
+ });
368
369
  }
369
370
 
370
371
  /** Recursively rotate all children's relative positions and angles. */
371
- export function rotateChildrenInPlace(
372
- children: PenNode[],
373
- angleDeltaDeg: number,
374
- ): PenNode[] {
375
- const rad = (angleDeltaDeg * Math.PI) / 180
376
- const cos = Math.cos(rad)
377
- const sin = Math.sin(rad)
372
+ export function rotateChildrenInPlace(children: PenNode[], angleDeltaDeg: number): PenNode[] {
373
+ const rad = (angleDeltaDeg * Math.PI) / 180;
374
+ const cos = Math.cos(rad);
375
+ const sin = Math.sin(rad);
378
376
  return children.map((child) => {
379
- const x = child.x ?? 0
380
- const y = child.y ?? 0
381
- const updated: Record<string, unknown> = { ...child }
382
- updated.x = x * cos - y * sin
383
- updated.y = x * sin + y * cos
384
- updated.rotation = ((child.rotation ?? 0) + angleDeltaDeg) % 360
377
+ const x = child.x ?? 0;
378
+ const y = child.y ?? 0;
379
+ const updated: Record<string, unknown> = { ...child };
380
+ updated.x = x * cos - y * sin;
381
+ updated.y = x * sin + y * cos;
382
+ updated.rotation = ((child.rotation ?? 0) + angleDeltaDeg) % 360;
385
383
  if ('children' in child && child.children) {
386
- updated.children = rotateChildrenInPlace(child.children, angleDeltaDeg)
384
+ updated.children = rotateChildrenInPlace(child.children, angleDeltaDeg);
387
385
  }
388
- return updated as unknown as PenNode
389
- })
386
+ return updated as unknown as PenNode;
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Recursively render a node tree summary for AI prompts.
392
+ * Shows node IDs, types, names, dimensions, roles, and child counts with indentation.
393
+ */
394
+ export function nodeTreeToSummary(nodes: PenNode[], depth: number = 0): string {
395
+ if (nodes.length === 0) return '';
396
+
397
+ const indent = ' '.repeat(depth);
398
+ return nodes
399
+ .map((node) => {
400
+ const n = node as unknown as Record<string, unknown>;
401
+ const dims = `${n.width ?? '?'}x${n.height ?? '?'}`;
402
+ const childCount = (n.children as PenNode[] | undefined)?.length ?? 0;
403
+ const role = n.role ? ` [${n.role}]` : '';
404
+ const line = `${indent}- [${node.id}] ${node.type} "${node.name ?? ''}" (${dims})${role}${childCount > 0 ? ` [${childCount} children]` : ''}`;
405
+ const children = n.children as PenNode[] | undefined;
406
+ if (children && children.length > 0) {
407
+ return line + '\n' + nodeTreeToSummary(children, depth + 1);
408
+ }
409
+ return line;
410
+ })
411
+ .join('\n');
390
412
  }