@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,651 @@
1
+ // packages/pen-core/src/merge/node-merge.ts
2
+ //
3
+ // Pure 3-way merge of PenDocument trees.
4
+
5
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types';
6
+ import {
7
+ indexNodesById,
8
+ nodeFieldsEqual,
9
+ stripChildren,
10
+ jsonEqual,
11
+ type IndexedNode,
12
+ } from './merge-helpers.js';
13
+
14
+ export interface MergeInput {
15
+ base: PenDocument;
16
+ ours: PenDocument;
17
+ theirs: PenDocument;
18
+ }
19
+
20
+ export type NodeConflictReason =
21
+ | 'both-modified-same-field'
22
+ | 'modify-vs-delete'
23
+ | 'add-vs-add-different'
24
+ | 'reparent-conflict';
25
+
26
+ export interface NodeConflict {
27
+ pageId: string | null;
28
+ nodeId: string;
29
+ reason: NodeConflictReason;
30
+ base: PenNode | null;
31
+ ours: PenNode | null;
32
+ theirs: PenNode | null;
33
+ }
34
+
35
+ export type DocFieldName = 'variables' | 'themes' | 'pages-order' | 'name' | 'version';
36
+
37
+ export interface DocFieldConflict {
38
+ field: DocFieldName;
39
+ path: string;
40
+ base: unknown;
41
+ ours: unknown;
42
+ theirs: unknown;
43
+ }
44
+
45
+ export interface MergeResult {
46
+ merged: PenDocument;
47
+ nodeConflicts: NodeConflict[];
48
+ docFieldConflicts: DocFieldConflict[];
49
+ }
50
+
51
+ /**
52
+ * Per-id decision the classification step produces. Invariants:
53
+ * - Every id in the merged-id union is either present in the decisions map
54
+ * OR omitted entirely. A null `mergedNode` means "explicitly deleted by
55
+ * the merge"; an absent map entry means "the rebuild step shouldn't see
56
+ * this id at all" (which currently never happens — every id produces a
57
+ * decision). The two states are kept distinct so the rebuild step in
58
+ * Task 9 can assert on its inputs.
59
+ * - When `mergedNode` is non-null, `mergedFields` excludes the `children`
60
+ * field. The rebuild step in Task 9 produces children by walking the
61
+ * decision map, not by reading the merged-node value.
62
+ * - `index` is a HINT for ordering inside the new parent. The rebuild step
63
+ * prefers ours' indices when both sides modified the same parent (this
64
+ * matches the spec's "fall back to ours" tiebreaker).
65
+ */
66
+ interface NodeDecision {
67
+ /** The merged node, with `children` left empty (the rebuild step fills them). null = deleted */
68
+ mergedNode: PenNode | null;
69
+ /** Where the merged node should live; null when mergedNode is null */
70
+ pageId: string | null;
71
+ parentId: string | null;
72
+ /** Position hint within parent.children; the rebuild step uses ours' index when available */
73
+ index: number;
74
+ }
75
+
76
+ /**
77
+ * Pure 3-way merge of PenDocument trees.
78
+ *
79
+ * Implementation is split:
80
+ * - Tasks 7-8: classify each node id, produce node-level conflicts
81
+ * - Task 9: rebuild the merged tree from decisions
82
+ * - Task 10: merge doc-level fields (variables, themes, pages-order, name, version)
83
+ * - Task 11: tests
84
+ */
85
+ export function mergeDocuments(input: MergeInput): MergeResult {
86
+ const { base, ours, theirs } = input;
87
+ const baseIdx = indexNodesById(base);
88
+ const oursIdx = indexNodesById(ours);
89
+ const theirsIdx = indexNodesById(theirs);
90
+
91
+ const allIds = new Set<string>([...baseIdx.keys(), ...oursIdx.keys(), ...theirsIdx.keys()]);
92
+
93
+ const decisions = new Map<string, NodeDecision>();
94
+ const nodeConflicts: NodeConflict[] = [];
95
+
96
+ for (const id of allIds) {
97
+ const b = baseIdx.get(id);
98
+ const o = oursIdx.get(id);
99
+ const t = theirsIdx.get(id);
100
+ const result = classifyNode(id, b, o, t);
101
+ if (result.conflict) nodeConflicts.push(result.conflict);
102
+ if (result.decision) decisions.set(id, result.decision);
103
+ }
104
+
105
+ const merged = rebuildDocument(ours, decisions);
106
+ const docFieldConflicts: DocFieldConflict[] = [];
107
+
108
+ // Merge doc-level scalar fields (name, version)
109
+ mergeDocScalarField(base, ours, theirs, 'name', merged, docFieldConflicts);
110
+ mergeDocScalarField(base, ours, theirs, 'version', merged, docFieldConflicts);
111
+
112
+ // Merge variables (object map)
113
+ merged.variables = mergeRecord(
114
+ base.variables,
115
+ ours.variables,
116
+ theirs.variables,
117
+ 'variables',
118
+ docFieldConflicts,
119
+ );
120
+ if (merged.variables && Object.keys(merged.variables).length === 0) {
121
+ delete merged.variables;
122
+ }
123
+
124
+ // Merge themes (object map of string[])
125
+ merged.themes = mergeRecord(base.themes, ours.themes, theirs.themes, 'themes', docFieldConflicts);
126
+ if (merged.themes && Object.keys(merged.themes).length === 0) {
127
+ delete merged.themes;
128
+ }
129
+
130
+ // Merge pages order (only if both sides have pages)
131
+ if (base.pages && ours.pages && theirs.pages) {
132
+ const reorderConflict = detectPagesOrderConflict(base.pages, ours.pages, theirs.pages);
133
+ if (reorderConflict) {
134
+ docFieldConflicts.push(reorderConflict);
135
+ }
136
+ }
137
+
138
+ return {
139
+ merged,
140
+ nodeConflicts,
141
+ docFieldConflicts,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * 3-way merge for a top-level scalar string field on PenDocument (name/version).
147
+ */
148
+ function mergeDocScalarField(
149
+ base: PenDocument,
150
+ ours: PenDocument,
151
+ theirs: PenDocument,
152
+ field: 'name' | 'version',
153
+ merged: PenDocument,
154
+ conflicts: DocFieldConflict[],
155
+ ): void {
156
+ const bv = base[field];
157
+ const ov = ours[field];
158
+ const tv = theirs[field];
159
+ if (jsonEqual(ov, bv) && jsonEqual(tv, bv)) {
160
+ if (bv !== undefined) merged[field] = bv as string;
161
+ return;
162
+ }
163
+ if (jsonEqual(ov, bv)) {
164
+ if (tv !== undefined) merged[field] = tv as string;
165
+ return;
166
+ }
167
+ if (jsonEqual(tv, bv)) {
168
+ if (ov !== undefined) merged[field] = ov as string;
169
+ return;
170
+ }
171
+ if (jsonEqual(ov, tv)) {
172
+ if (ov !== undefined) merged[field] = ov as string;
173
+ return;
174
+ }
175
+ // Both changed differently — conflict; take ours as placeholder.
176
+ if (ov !== undefined) merged[field] = ov as string;
177
+ conflicts.push({
178
+ field,
179
+ path: field,
180
+ base: bv,
181
+ ours: ov,
182
+ theirs: tv,
183
+ });
184
+ }
185
+
186
+ /**
187
+ * 3-way merge for an object record (variables, themes). Walks every key from
188
+ * the union and applies the per-key merge rules. Returns the merged record.
189
+ */
190
+ function mergeRecord<V>(
191
+ base: Record<string, V> | undefined,
192
+ ours: Record<string, V> | undefined,
193
+ theirs: Record<string, V> | undefined,
194
+ fieldName: 'variables' | 'themes',
195
+ conflicts: DocFieldConflict[],
196
+ ): Record<string, V> | undefined {
197
+ if (!base && !ours && !theirs) return undefined;
198
+ const b = base ?? {};
199
+ const o = ours ?? {};
200
+ const t = theirs ?? {};
201
+ const allKeys = new Set<string>([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)]);
202
+ const merged: Record<string, V> = {};
203
+ for (const key of allKeys) {
204
+ const bv = b[key];
205
+ const ov = o[key];
206
+ const tv = t[key];
207
+ const inBase = key in b;
208
+ const inOurs = key in o;
209
+ const inTheirs = key in t;
210
+
211
+ // Use the same 7-grid logic as nodes.
212
+ if (inBase && inOurs && inTheirs) {
213
+ // ✓✓✓
214
+ if (jsonEqual(ov, bv) && jsonEqual(tv, bv)) {
215
+ merged[key] = bv;
216
+ } else if (jsonEqual(ov, bv)) {
217
+ merged[key] = tv;
218
+ } else if (jsonEqual(tv, bv)) {
219
+ merged[key] = ov;
220
+ } else if (jsonEqual(ov, tv)) {
221
+ merged[key] = ov;
222
+ } else {
223
+ merged[key] = ov;
224
+ conflicts.push({
225
+ field: fieldName,
226
+ path: `${fieldName}.${key}`,
227
+ base: bv,
228
+ ours: ov,
229
+ theirs: tv,
230
+ });
231
+ }
232
+ } else if (inBase && inOurs && !inTheirs) {
233
+ // ✓✓✗ — theirs deleted
234
+ if (jsonEqual(ov, bv)) {
235
+ // ours unchanged → delete
236
+ } else {
237
+ // ours modified → conflict; take ours
238
+ merged[key] = ov;
239
+ conflicts.push({
240
+ field: fieldName,
241
+ path: `${fieldName}.${key}`,
242
+ base: bv,
243
+ ours: ov,
244
+ theirs: undefined,
245
+ });
246
+ }
247
+ } else if (inBase && !inOurs && inTheirs) {
248
+ // ✓✗✓ — ours deleted
249
+ if (jsonEqual(tv, bv)) {
250
+ // theirs unchanged → delete
251
+ } else {
252
+ // theirs modified → conflict; take theirs
253
+ merged[key] = tv;
254
+ conflicts.push({
255
+ field: fieldName,
256
+ path: `${fieldName}.${key}`,
257
+ base: bv,
258
+ ours: undefined,
259
+ theirs: tv,
260
+ });
261
+ }
262
+ } else if (inBase && !inOurs && !inTheirs) {
263
+ // ✓✗✗ — both deleted
264
+ } else if (!inBase && inOurs && inTheirs) {
265
+ // ✗✓✓ — both added
266
+ if (jsonEqual(ov, tv)) {
267
+ merged[key] = ov;
268
+ } else {
269
+ merged[key] = ov;
270
+ conflicts.push({
271
+ field: fieldName,
272
+ path: `${fieldName}.${key}`,
273
+ base: undefined,
274
+ ours: ov,
275
+ theirs: tv,
276
+ });
277
+ }
278
+ } else if (!inBase && inOurs && !inTheirs) {
279
+ merged[key] = ov;
280
+ } else if (!inBase && !inOurs && inTheirs) {
281
+ merged[key] = tv;
282
+ }
283
+ }
284
+ return merged;
285
+ }
286
+
287
+ /**
288
+ * Detect a `pages-order` conflict. Returns a DocFieldConflict if both sides
289
+ * reordered pages differently from base; otherwise returns null. The merge
290
+ * leaves the pages in ours' order (the rebuildDocument step already did this).
291
+ */
292
+ function detectPagesOrderConflict(
293
+ basePages: PenDocument['pages'],
294
+ oursPages: PenDocument['pages'],
295
+ theirsPages: PenDocument['pages'],
296
+ ): DocFieldConflict | null {
297
+ if (!basePages || !oursPages || !theirsPages) return null;
298
+ const baseOrder = basePages.map((p) => p.id);
299
+ const oursOrder = oursPages.map((p) => p.id);
300
+ const theirsOrder = theirsPages.map((p) => p.id);
301
+ const oursReordered = !sequenceEqual(baseOrder, oursOrder);
302
+ const theirsReordered = !sequenceEqual(baseOrder, theirsOrder);
303
+ if (!oursReordered || !theirsReordered) return null;
304
+ if (sequenceEqual(oursOrder, theirsOrder)) return null;
305
+ return {
306
+ field: 'pages-order',
307
+ path: 'pages-order',
308
+ base: baseOrder,
309
+ ours: oursOrder,
310
+ theirs: theirsOrder,
311
+ };
312
+ }
313
+
314
+ function sequenceEqual(a: string[], b: string[]): boolean {
315
+ if (a.length !== b.length) return false;
316
+ for (let i = 0; i < a.length; i++) {
317
+ if (a[i] !== b[i]) return false;
318
+ }
319
+ return true;
320
+ }
321
+
322
+ /**
323
+ * Rebuild a PenDocument from a decision map. Uses ours' page structure as the
324
+ * scaffold (so users see their own page list). For each merged node, attach it
325
+ * to its merged parent at its merged index.
326
+ *
327
+ * Algorithm:
328
+ * 1. Group decisions by (pageId, parentId).
329
+ * 2. For each page in ours (or the legacy single page):
330
+ * a. Build the page's top-level children list from decisions where
331
+ * parentId === null and pageId === thisPageId.
332
+ * b. Recursively populate each container's children from decisions where
333
+ * parentId === containerId.
334
+ * 3. Within a parent, sort by `index` ascending; ties broken by id for
335
+ * determinism.
336
+ */
337
+ function rebuildDocument(scaffold: PenDocument, decisions: Map<string, NodeDecision>): PenDocument {
338
+ // Group by parent for fast child lookup.
339
+ const byParent = new Map<string, NodeDecision[]>();
340
+ for (const decision of decisions.values()) {
341
+ if (!decision.mergedNode) continue;
342
+ const key = `${decision.pageId ?? '_'}::${decision.parentId ?? '_'}`;
343
+ let bucket = byParent.get(key);
344
+ if (!bucket) {
345
+ bucket = [];
346
+ byParent.set(key, bucket);
347
+ }
348
+ bucket.push(decision);
349
+ }
350
+
351
+ function buildChildren(pageId: string | null, parentId: string | null): PenNode[] {
352
+ const key = `${pageId ?? '_'}::${parentId ?? '_'}`;
353
+ const bucket = byParent.get(key);
354
+ if (!bucket) return [];
355
+ // Stable sort by (index, id).
356
+ const sorted = [...bucket].sort((a, b) => {
357
+ if (a.index !== b.index) return a.index - b.index;
358
+ const ai = a.mergedNode ? a.mergedNode.id : '';
359
+ const bi = b.mergedNode ? b.mergedNode.id : '';
360
+ return ai.localeCompare(bi);
361
+ });
362
+ return sorted.map((decision) => {
363
+ const node = decision.mergedNode!;
364
+ const children = buildChildren(pageId, node.id);
365
+ // Only attach a children array if the original node type supports it
366
+ // (containers). Following the existing PenDocument convention, attach
367
+ // the children only if the node already had `children` or if there are
368
+ // any children to attach.
369
+ if (children.length > 0 || (node as { children?: PenNode[] }).children !== undefined) {
370
+ return { ...node, children } as PenNode;
371
+ }
372
+ return node;
373
+ });
374
+ }
375
+
376
+ // Use ours' page structure as the scaffold.
377
+ if (scaffold.pages && scaffold.pages.length > 0) {
378
+ const newPages = scaffold.pages.map((page) => ({
379
+ id: page.id,
380
+ name: page.name,
381
+ children: buildChildren(page.id, null),
382
+ }));
383
+ return {
384
+ ...scaffold,
385
+ pages: newPages,
386
+ children: [],
387
+ };
388
+ }
389
+ // Legacy single-page mode.
390
+ return {
391
+ ...scaffold,
392
+ children: buildChildren(null, null),
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Classify a single node id across the three trees and produce both a decision
398
+ * (the merged node value to use, or null for delete) and an optional conflict.
399
+ *
400
+ * The 7-grid:
401
+ * ✓✓✓ → field-level merge (may produce conflict)
402
+ * ✓✓✗ → modify-vs-delete OR clean delete
403
+ * ✓✗✓ → modify-vs-delete OR clean delete (symmetric)
404
+ * ✓✗✗ → both deleted; no merged node
405
+ * ✗✓✓ → add-vs-add (equal → take ours; different → conflict)
406
+ * ✗✓✗ → only ours added; take ours
407
+ * ✗✗✓ → only theirs added; take theirs
408
+ */
409
+ function classifyNode(
410
+ id: string,
411
+ b: IndexedNode | undefined,
412
+ o: IndexedNode | undefined,
413
+ t: IndexedNode | undefined,
414
+ ): { decision: NodeDecision | null; conflict: NodeConflict | null } {
415
+ // ✓✓✓
416
+ if (b && o && t) {
417
+ return mergeThreeWay(id, b, o, t);
418
+ }
419
+ // ✓✓✗ — theirs deleted
420
+ if (b && o && !t) {
421
+ if (nodeFieldsEqual(b.node, o.node)) {
422
+ // ours unchanged, theirs deleted → delete
423
+ return { decision: null, conflict: null };
424
+ }
425
+ // ours modified, theirs deleted → conflict
426
+ return {
427
+ decision: {
428
+ mergedNode: o.node,
429
+ pageId: o.pageId,
430
+ parentId: o.parentId,
431
+ index: o.index,
432
+ },
433
+ conflict: {
434
+ pageId: o.pageId,
435
+ nodeId: id,
436
+ reason: 'modify-vs-delete',
437
+ base: b.node,
438
+ ours: o.node,
439
+ theirs: null,
440
+ },
441
+ };
442
+ }
443
+ // ✓✗✓ — ours deleted
444
+ if (b && !o && t) {
445
+ if (nodeFieldsEqual(b.node, t.node)) {
446
+ return { decision: null, conflict: null };
447
+ }
448
+ return {
449
+ decision: {
450
+ mergedNode: t.node,
451
+ pageId: t.pageId,
452
+ parentId: t.parentId,
453
+ index: t.index,
454
+ },
455
+ conflict: {
456
+ pageId: t.pageId,
457
+ nodeId: id,
458
+ reason: 'modify-vs-delete',
459
+ base: b.node,
460
+ ours: null,
461
+ theirs: t.node,
462
+ },
463
+ };
464
+ }
465
+ // ✓✗✗ — both deleted
466
+ if (b && !o && !t) {
467
+ return { decision: null, conflict: null };
468
+ }
469
+ // ✗✓✓ — both added
470
+ if (!b && o && t) {
471
+ if (nodeFieldsEqual(o.node, t.node)) {
472
+ return {
473
+ decision: {
474
+ mergedNode: o.node,
475
+ pageId: o.pageId,
476
+ parentId: o.parentId,
477
+ index: o.index,
478
+ },
479
+ conflict: null,
480
+ };
481
+ }
482
+ return {
483
+ decision: {
484
+ mergedNode: o.node,
485
+ pageId: o.pageId,
486
+ parentId: o.parentId,
487
+ index: o.index,
488
+ },
489
+ conflict: {
490
+ pageId: o.pageId,
491
+ nodeId: id,
492
+ reason: 'add-vs-add-different',
493
+ base: null,
494
+ ours: o.node,
495
+ theirs: t.node,
496
+ },
497
+ };
498
+ }
499
+ // ✗✓✗ — ours only
500
+ if (!b && o && !t) {
501
+ return {
502
+ decision: {
503
+ mergedNode: o.node,
504
+ pageId: o.pageId,
505
+ parentId: o.parentId,
506
+ index: o.index,
507
+ },
508
+ conflict: null,
509
+ };
510
+ }
511
+ // ✗✗✓ — theirs only
512
+ if (!b && !o && t) {
513
+ return {
514
+ decision: {
515
+ mergedNode: t.node,
516
+ pageId: t.pageId,
517
+ parentId: t.parentId,
518
+ index: t.index,
519
+ },
520
+ conflict: null,
521
+ };
522
+ }
523
+ // Unreachable: id appeared in the union but is in none of the three indices.
524
+ return { decision: null, conflict: null };
525
+ }
526
+
527
+ /**
528
+ * Three-way present case: do field-level merge between b, o, t and detect both
529
+ * `both-modified-same-field` and `reparent-conflict` cases.
530
+ */
531
+ function mergeThreeWay(
532
+ id: string,
533
+ b: IndexedNode,
534
+ o: IndexedNode,
535
+ t: IndexedNode,
536
+ ): { decision: NodeDecision | null; conflict: NodeConflict | null } {
537
+ // First: detect reparent conflict.
538
+ const oursMoved = o.parentId !== b.parentId || o.pageId !== b.pageId;
539
+ const theirsMoved = t.parentId !== b.parentId || t.pageId !== b.pageId;
540
+ let mergedParentId = b.parentId;
541
+ let mergedPageId = b.pageId;
542
+ let reparentConflict = false;
543
+ if (oursMoved && theirsMoved) {
544
+ if (o.parentId === t.parentId && o.pageId === t.pageId) {
545
+ // Both moved to the same place — auto-merge to that.
546
+ mergedParentId = o.parentId;
547
+ mergedPageId = o.pageId;
548
+ } else {
549
+ // Diverging moves.
550
+ reparentConflict = true;
551
+ mergedParentId = o.parentId;
552
+ mergedPageId = o.pageId;
553
+ }
554
+ } else if (oursMoved) {
555
+ mergedParentId = o.parentId;
556
+ mergedPageId = o.pageId;
557
+ } else if (theirsMoved) {
558
+ mergedParentId = t.parentId;
559
+ mergedPageId = t.pageId;
560
+ }
561
+
562
+ // Second: per-field 3-way merge of atomic fields.
563
+ const baseFields = stripChildren(b.node) as Record<string, unknown>;
564
+ const oursFields = stripChildren(o.node) as Record<string, unknown>;
565
+ const theirsFields = stripChildren(t.node) as Record<string, unknown>;
566
+ const allKeys = new Set<string>([
567
+ ...Object.keys(baseFields),
568
+ ...Object.keys(oursFields),
569
+ ...Object.keys(theirsFields),
570
+ ]);
571
+
572
+ const mergedFields: Record<string, unknown> = {};
573
+ let fieldConflict = false;
574
+ for (const key of allKeys) {
575
+ const bv = baseFields[key];
576
+ const ov = oursFields[key];
577
+ const tv = theirsFields[key];
578
+ const oursChanged = !jsonEqual(ov, bv);
579
+ const theirsChanged = !jsonEqual(tv, bv);
580
+ if (!oursChanged && !theirsChanged) {
581
+ if (bv !== undefined) mergedFields[key] = bv;
582
+ continue;
583
+ }
584
+ if (oursChanged && !theirsChanged) {
585
+ if (ov !== undefined) mergedFields[key] = ov;
586
+ continue;
587
+ }
588
+ if (!oursChanged && theirsChanged) {
589
+ if (tv !== undefined) mergedFields[key] = tv;
590
+ continue;
591
+ }
592
+ // Both changed.
593
+ if (jsonEqual(ov, tv)) {
594
+ if (ov !== undefined) mergedFields[key] = ov;
595
+ continue;
596
+ }
597
+ // Conflict — take ours as placeholder.
598
+ fieldConflict = true;
599
+ if (ov !== undefined) mergedFields[key] = ov;
600
+ }
601
+
602
+ const mergedNode = mergedFields as unknown as PenNode;
603
+
604
+ if (reparentConflict) {
605
+ return {
606
+ decision: {
607
+ mergedNode,
608
+ pageId: mergedPageId,
609
+ parentId: mergedParentId,
610
+ index: o.index,
611
+ },
612
+ conflict: {
613
+ pageId: mergedPageId,
614
+ nodeId: id,
615
+ reason: 'reparent-conflict',
616
+ base: b.node,
617
+ ours: o.node,
618
+ theirs: t.node,
619
+ },
620
+ };
621
+ }
622
+
623
+ if (fieldConflict) {
624
+ return {
625
+ decision: {
626
+ mergedNode,
627
+ pageId: mergedPageId,
628
+ parentId: mergedParentId,
629
+ index: o.index,
630
+ },
631
+ conflict: {
632
+ pageId: mergedPageId,
633
+ nodeId: id,
634
+ reason: 'both-modified-same-field',
635
+ base: b.node,
636
+ ours: o.node,
637
+ theirs: t.node,
638
+ },
639
+ };
640
+ }
641
+
642
+ return {
643
+ decision: {
644
+ mergedNode,
645
+ pageId: mergedPageId,
646
+ parentId: mergedParentId,
647
+ index: o.index,
648
+ },
649
+ conflict: null,
650
+ };
651
+ }
@@ -1,4 +1,4 @@
1
- import type { PenNode } from '@zseven-w/pen-types'
1
+ import type { PenNode } from '@zseven-w/pen-types';
2
2
 
3
3
  /**
4
4
  * Check if a node is a badge/overlay that uses absolute positioning
@@ -6,9 +6,22 @@ import type { PenNode } from '@zseven-w/pen-types'
6
6
  */
7
7
  export function isBadgeOverlayNode(node: PenNode): boolean {
8
8
  if ('role' in node) {
9
- const role = (node as { role?: string }).role
10
- if (role === 'badge' || role === 'pill' || role === 'tag') return true
9
+ const role = (node as { role?: string }).role;
10
+ if (role === 'badge' || role === 'pill' || role === 'tag') return true;
11
11
  }
12
- const name = (node.name ?? '').toLowerCase()
13
- return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
12
+ const name = (node.name ?? '').toLowerCase();
13
+ return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name);
14
+ }
15
+
16
+ /**
17
+ * Convert a name string to PascalCase.
18
+ * Strips non-alphanumeric characters and joins words.
19
+ */
20
+ export function sanitizeName(name: string): string {
21
+ return name
22
+ .replace(/[^a-zA-Z0-9\s-_]/g, '')
23
+ .split(/[\s\-_]+/)
24
+ .filter(Boolean)
25
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
26
+ .join('');
14
27
  }