@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,147 @@
1
+ import type { PenNode } from '@zseven-w/pen-types';
2
+
3
+ /**
4
+ * Detect and repair "fake phone mockup" frames — frames that look like a
5
+ * phone mockup (cornerRadius 28-40 + width 240-320, or literally named
6
+ * "Phone Mockup") but are stuffed with multiple children or use a horizontal/
7
+ * vertical layout. A genuine phone mockup is `ONE` frame with at most one
8
+ * placeholder child and `layout: 'none'`.
9
+ *
10
+ * Failure mode this addresses: weaker AI sub-agents (M2 family, GLM, Kimi)
11
+ * sometimes read a generic "Phone mockup = ONE frame, cornerRadius 32" prompt
12
+ * fragment and apply those visual properties to their own root frame, then
13
+ * shove the entire section content inside. The resulting wrapper has a
14
+ * 260px-wide horizontal layout that compresses every section into a 40px
15
+ * column — visually a complete mess (see 2026-04-06 health-tracker case).
16
+ *
17
+ * Two recovery modes:
18
+ *
19
+ * 1. **Unwrap (preferred)** — when the fake wrapper is a CHILD of `node`, we
20
+ * drop it and promote its children up one level. Visual styling is
21
+ * discarded along with the wrapper.
22
+ *
23
+ * 2. **Sanitize (fallback)** — when `node` ITSELF is the fake wrapper (the
24
+ * sub-agent's own root frame is the fake; we have no parent in scope),
25
+ * we strip the phone bezel visuals (cornerRadius, fixed width, layout) and
26
+ * rename it. Children stay; they may be duplicates, but at least they
27
+ * render in a normal vertical stack instead of being crushed into a 260px
28
+ * horizontal column.
29
+ *
30
+ * Returns `true` if any node was mutated. Callers operating on store-owned
31
+ * nodes MUST publish a Zustand update when this returns true — the in-place
32
+ * mutation does not by itself notify subscribers, so canvas would otherwise
33
+ * keep showing the old (fake-mockup) state until something else triggered a
34
+ * store write.
35
+ */
36
+ export function unwrapFakePhoneMockups(node: PenNode): boolean {
37
+ let changed = false;
38
+
39
+ // Mode (2): self-sanitize if the root itself is the fake wrapper.
40
+ if (isFakePhoneMockup(node)) {
41
+ sanitizeFakePhoneMockupRoot(node);
42
+ changed = true;
43
+ }
44
+
45
+ if (!('children' in node) || !Array.isArray(node.children)) return changed;
46
+
47
+ // Mode (1): drop fake wrappers among the children, promote their content.
48
+ const newChildren: PenNode[] = [];
49
+ let unwrappedAnyChild = false;
50
+ for (const child of node.children) {
51
+ if (isFakePhoneMockup(child)) {
52
+ const grandchildren =
53
+ ('children' in child && Array.isArray(child.children) ? child.children : []) ?? [];
54
+ for (const gc of grandchildren) newChildren.push(gc);
55
+ unwrappedAnyChild = true;
56
+ } else {
57
+ newChildren.push(child);
58
+ }
59
+ }
60
+ if (unwrappedAnyChild) {
61
+ node.children = newChildren;
62
+ changed = true;
63
+ }
64
+
65
+ // Recurse into the (possibly updated) children. Promoted grandchildren are
66
+ // visited too — if a fake mockup wrapped another fake mockup, both are
67
+ // handled in a single pass.
68
+ for (const child of node.children) {
69
+ if (unwrapFakePhoneMockups(child)) {
70
+ changed = true;
71
+ }
72
+ }
73
+
74
+ return changed;
75
+ }
76
+
77
+ /**
78
+ * Strip phone bezel visual signature from a frame in place. Used when the
79
+ * frame itself is the fake wrapper and we cannot detach it from a parent.
80
+ */
81
+ function sanitizeFakePhoneMockupRoot(node: PenNode): void {
82
+ const rec = node as PenNode & {
83
+ name?: string;
84
+ cornerRadius?: unknown;
85
+ width?: unknown;
86
+ height?: unknown;
87
+ layout?: 'none' | 'vertical' | 'horizontal';
88
+ fill?: unknown;
89
+ };
90
+ // Drop misleading visuals
91
+ delete rec.cornerRadius;
92
+ // Restore a sensible container width — fill_container lets the parent's
93
+ // layout decide actual width instead of locking us at 260px.
94
+ rec.width = 'fill_container';
95
+ // Drop the fixed bezel height; let content drive intrinsic height.
96
+ rec.height = 'fit_content';
97
+ // Force vertical so children stack instead of getting compressed
98
+ // horizontally inside a fake bezel.
99
+ rec.layout = 'vertical';
100
+ // Clear the misleading "Phone Mockup" name so downstream role inference
101
+ // doesn't act on it.
102
+ if (typeof rec.name === 'string' && /phone\s*mockup|app\s*mockup/i.test(rec.name)) {
103
+ rec.name = 'Section';
104
+ }
105
+ }
106
+
107
+ function isFakePhoneMockup(node: PenNode): boolean {
108
+ if (node.type !== 'frame') return false;
109
+ if (!('children' in node) || !Array.isArray(node.children)) return false;
110
+
111
+ // Detection signal #1: literal name match. Models often copy the prompt's
112
+ // wording verbatim into the node name.
113
+ const name = (node.name ?? '').toLowerCase();
114
+ const hasPhoneName = /phone\s*mockup|app\s*mockup|手机\s*样机|手机\s*模型|device\s*frame/.test(
115
+ name,
116
+ );
117
+
118
+ // Detection signal #2: visual signature. The combination of large radius
119
+ // (>= 28) and narrow width (240-320) is the classic phone bezel.
120
+ const cornerR = readCornerRadius(node);
121
+ const widthNum = typeof node.width === 'number' ? node.width : null;
122
+ const hasPhoneShape =
123
+ cornerR != null &&
124
+ cornerR >= 28 &&
125
+ cornerR <= 40 &&
126
+ widthNum != null &&
127
+ widthNum >= 240 &&
128
+ widthNum <= 320;
129
+
130
+ if (!hasPhoneName && !hasPhoneShape) return false;
131
+
132
+ // A real phone mockup is ONE frame with at most one placeholder child and
133
+ // `layout: 'none'` (or layout unset). Anything more elaborate is a sub-agent
134
+ // mistake.
135
+ const childCount = node.children.length;
136
+ const layout = (node as PenNode & { layout?: string }).layout;
137
+ const tooManyChildren = childCount > 1;
138
+ const wrongLayout = layout === 'horizontal' || layout === 'vertical';
139
+ return tooManyChildren || wrongLayout;
140
+ }
141
+
142
+ function readCornerRadius(node: PenNode): number | null {
143
+ const cr = (node as PenNode & { cornerRadius?: number | number[] }).cornerRadius;
144
+ if (typeof cr === 'number') return cr;
145
+ if (Array.isArray(cr) && cr.length > 0 && typeof cr[0] === 'number') return cr[0];
146
+ return null;
147
+ }
@@ -0,0 +1,16 @@
1
+ // packages/pen-core/src/merge/index.ts
2
+ //
3
+ // Public surface for the merge module.
4
+
5
+ export type { NodePatch } from './node-diff.js';
6
+ export { diffDocuments } from './node-diff.js';
7
+
8
+ export type {
9
+ MergeInput,
10
+ MergeResult,
11
+ NodeConflict,
12
+ NodeConflictReason,
13
+ DocFieldConflict,
14
+ DocFieldName,
15
+ } from './node-merge.js';
16
+ export { mergeDocuments } from './node-merge.js';
@@ -0,0 +1,113 @@
1
+ // packages/pen-core/src/merge/merge-helpers.ts
2
+ //
3
+ // Pure indexing and walking utilities shared by node-diff.ts and node-merge.ts.
4
+ // Not exported from the module's public surface — these are internal helpers.
5
+
6
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types';
7
+
8
+ /**
9
+ * Information stored per indexed node, capturing both the node itself and
10
+ * the structural context that the merge algorithm needs (page, parent, index).
11
+ */
12
+ export interface IndexedNode {
13
+ /** null for legacy single-page documents (no pages array) */
14
+ pageId: string | null;
15
+ /** null when the node sits at the top of a page (or top of legacy children) */
16
+ parentId: string | null;
17
+ /** position within parent.children (or top-level array) */
18
+ index: number;
19
+ node: PenNode;
20
+ }
21
+
22
+ /**
23
+ * Walk a document and produce a Map<nodeId, IndexedNode>. Handles both
24
+ * `pages` and legacy `children` shapes uniformly.
25
+ */
26
+ export function indexNodesById(doc: PenDocument): Map<string, IndexedNode> {
27
+ const out = new Map<string, IndexedNode>();
28
+ for (const page of getAllPages(doc)) {
29
+ walk(page.children, page.id, null, out);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function walk(
35
+ nodes: PenNode[],
36
+ pageId: string | null,
37
+ parentId: string | null,
38
+ out: Map<string, IndexedNode>,
39
+ ): void {
40
+ nodes.forEach((node, index) => {
41
+ out.set(node.id, { pageId, parentId, index, node });
42
+ const children = (node as { children?: PenNode[] }).children;
43
+ if (children && children.length > 0) {
44
+ walk(children, pageId, node.id, out);
45
+ }
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Normalize a document into a list of pages, regardless of whether it uses
51
+ * the explicit `pages` array or the legacy `children` array. Legacy mode
52
+ * produces a single synthetic page with `id = null`.
53
+ */
54
+ export function getAllPages(doc: PenDocument): Array<{ id: string | null; children: PenNode[] }> {
55
+ if (doc.pages && doc.pages.length > 0) {
56
+ return doc.pages.map((p) => ({ id: p.id, children: p.children }));
57
+ }
58
+ return [{ id: null, children: doc.children ?? [] }];
59
+ }
60
+
61
+ /**
62
+ * Compare two nodes by atomic fields only (everything except `children`).
63
+ * Returns true if they would produce the same fields-only diff.
64
+ */
65
+ export function nodeFieldsEqual(a: PenNode, b: PenNode): boolean {
66
+ const aFields = stripChildren(a);
67
+ const bFields = stripChildren(b);
68
+ return jsonEqual(aFields, bFields);
69
+ }
70
+
71
+ /**
72
+ * Return a shallow copy of `node` with the `children` field removed.
73
+ * Used everywhere we want to compare or diff a node's atomic fields without
74
+ * the recursive children noise.
75
+ */
76
+ export function stripChildren<T extends PenNode>(node: T): Omit<T, 'children'> {
77
+ // Use destructuring; TS-safe.
78
+ const copy = { ...node } as T & { children?: unknown };
79
+ delete copy.children;
80
+ return copy as Omit<T, 'children'>;
81
+ }
82
+
83
+ /**
84
+ * Deep value-equality via JSON canonicalization. Used by nodeFieldsEqual and
85
+ * by the doc-field merge for comparing variable values, theme entries, etc.
86
+ *
87
+ * Important: this is intentionally simple. PenNode field values are JSON-safe
88
+ * (numbers, strings, booleans, plain objects, arrays). No Dates, Maps, Sets,
89
+ * functions, etc. live in PenDocument.
90
+ */
91
+ export function jsonEqual(a: unknown, b: unknown): boolean {
92
+ if (a === b) return true;
93
+ if (a === null || b === null) return false;
94
+ if (typeof a !== typeof b) return false;
95
+ if (typeof a !== 'object') return false;
96
+ // Both are non-null objects (or arrays)
97
+ return JSON.stringify(canonicalize(a)) === JSON.stringify(canonicalize(b));
98
+ }
99
+
100
+ /**
101
+ * Sort object keys recursively so JSON.stringify produces a deterministic string
102
+ * regardless of insertion order.
103
+ */
104
+ function canonicalize(value: unknown): unknown {
105
+ if (value === null || typeof value !== 'object') return value;
106
+ if (Array.isArray(value)) return value.map(canonicalize);
107
+ const sortedKeys = Object.keys(value as Record<string, unknown>).sort();
108
+ const out: Record<string, unknown> = {};
109
+ for (const key of sortedKeys) {
110
+ out[key] = canonicalize((value as Record<string, unknown>)[key]);
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,123 @@
1
+ // packages/pen-core/src/merge/node-diff.ts
2
+ //
3
+ // One-direction diff: compute the patches needed to transform `base` into `next`.
4
+
5
+ import type { PenNode, PenDocument } from '@zseven-w/pen-types';
6
+ import { indexNodesById, nodeFieldsEqual, stripChildren, jsonEqual } from './merge-helpers.js';
7
+
8
+ export interface NodePatch {
9
+ op: 'add' | 'remove' | 'modify' | 'move';
10
+ /** null for legacy single-page documents (no pages array) */
11
+ pageId: string | null;
12
+ nodeId: string;
13
+ /** for `add` / `move`: target parent id; null = top-level on the page */
14
+ parentId?: string | null;
15
+ /** for `add` / `move`: target index within parent's children */
16
+ index?: number;
17
+ /** for `modify`: only the atomic fields that changed (never includes `children`) */
18
+ fields?: Partial<PenNode>;
19
+ /** for `modify`: pre-change values of those same fields, used by 3-way merge */
20
+ beforeFields?: Partial<PenNode>;
21
+ }
22
+
23
+ /**
24
+ * Compute the patches needed to transform `base` into `next`. Walks both trees
25
+ * by node id and emits one of `add` / `remove` / `modify` / `move` per change.
26
+ *
27
+ * Algorithm:
28
+ * 1. Index both documents by node id.
29
+ * 2. For each id in base ∪ next:
30
+ * - in next only → `add`
31
+ * - in base only → `remove`
32
+ * - in both → check parent/index (if changed → `move`) and atomic fields
33
+ * (if any changed → `modify`); a single id may produce both a `move`
34
+ * and a `modify`.
35
+ */
36
+ export function diffDocuments(base: PenDocument, next: PenDocument): NodePatch[] {
37
+ const baseIdx = indexNodesById(base);
38
+ const nextIdx = indexNodesById(next);
39
+ const allIds = new Set<string>([...baseIdx.keys(), ...nextIdx.keys()]);
40
+ const patches: NodePatch[] = [];
41
+
42
+ for (const id of allIds) {
43
+ const b = baseIdx.get(id);
44
+ const n = nextIdx.get(id);
45
+
46
+ if (!b && n) {
47
+ // Added.
48
+ patches.push({
49
+ op: 'add',
50
+ pageId: n.pageId,
51
+ nodeId: id,
52
+ parentId: n.parentId,
53
+ index: n.index,
54
+ fields: stripChildren(n.node) as Partial<PenNode>,
55
+ });
56
+ continue;
57
+ }
58
+
59
+ if (b && !n) {
60
+ // Removed.
61
+ patches.push({
62
+ op: 'remove',
63
+ pageId: b.pageId,
64
+ nodeId: id,
65
+ });
66
+ continue;
67
+ }
68
+
69
+ if (b && n) {
70
+ // Present in both. Check for `move` (parent or page changed) and `modify`
71
+ // (atomic fields changed). They are independent — one node may produce
72
+ // both kinds of patches.
73
+ const moved = b.parentId !== n.parentId || b.pageId !== n.pageId || b.index !== n.index;
74
+ if (moved) {
75
+ patches.push({
76
+ op: 'move',
77
+ pageId: n.pageId,
78
+ nodeId: id,
79
+ parentId: n.parentId,
80
+ index: n.index,
81
+ });
82
+ }
83
+ if (!nodeFieldsEqual(b.node, n.node)) {
84
+ const { changed, before } = diffFields(b.node, n.node);
85
+ patches.push({
86
+ op: 'modify',
87
+ pageId: n.pageId,
88
+ nodeId: id,
89
+ fields: changed,
90
+ beforeFields: before,
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ return patches;
97
+ }
98
+
99
+ /**
100
+ * Compute the per-field delta between two nodes. Returns the keys that changed
101
+ * (in `next`) along with the original values (from `base`). Skips the `children`
102
+ * field — it's handled separately by the recursive walk.
103
+ */
104
+ function diffFields(
105
+ base: PenNode,
106
+ next: PenNode,
107
+ ): { changed: Partial<PenNode>; before: Partial<PenNode> } {
108
+ const baseStripped = stripChildren(base) as Record<string, unknown>;
109
+ const nextStripped = stripChildren(next) as Record<string, unknown>;
110
+ const allKeys = new Set<string>([...Object.keys(baseStripped), ...Object.keys(nextStripped)]);
111
+ const changed: Record<string, unknown> = {};
112
+ const before: Record<string, unknown> = {};
113
+ for (const key of allKeys) {
114
+ if (!jsonEqual(baseStripped[key], nextStripped[key])) {
115
+ changed[key] = nextStripped[key];
116
+ before[key] = baseStripped[key];
117
+ }
118
+ }
119
+ return {
120
+ changed: changed as Partial<PenNode>,
121
+ before: before as Partial<PenNode>,
122
+ };
123
+ }