@pygmalionjs/pygmalion 0.8.1 → 0.10.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 (150) hide show
  1. package/README.md +34 -0
  2. package/binding-source.d.ts +25 -0
  3. package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BUOlJcUa.js} +17 -17
  4. package/dist-lib/pygmalion.js +24524 -12555
  5. package/dist-lib/{runtime-BAy8W1mJ.js → runtime-BOfDlGPR.js} +12438 -11118
  6. package/dist-lib/style.css +1 -1
  7. package/dist-lib/testing.js +19 -19
  8. package/dist-lib/types/binding/catalog.d.ts +10 -0
  9. package/dist-lib/types/binding/contracts.d.ts +202 -0
  10. package/dist-lib/types/binding/documentCommands.d.ts +38 -0
  11. package/dist-lib/types/binding/duplicateMetadata.d.ts +8 -0
  12. package/dist-lib/types/binding/index.d.ts +7 -0
  13. package/dist-lib/types/binding/preview.d.ts +37 -0
  14. package/dist-lib/types/binding/source.d.ts +109 -0
  15. package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
  16. package/dist-lib/types/binding/validate.d.ts +8 -0
  17. package/dist-lib/types/core/runtime.d.ts +6 -0
  18. package/dist-lib/types/document/appearance.d.ts +112 -0
  19. package/dist-lib/types/document/assets.d.ts +204 -0
  20. package/dist-lib/types/document/components.d.ts +169 -0
  21. package/dist-lib/types/document/contracts.d.ts +171 -0
  22. package/dist-lib/types/document/engine.d.ts +20 -0
  23. package/dist-lib/types/document/exportSettings.d.ts +45 -0
  24. package/dist-lib/types/document/geometry.d.ts +11 -0
  25. package/dist-lib/types/document/layout.d.ts +53 -0
  26. package/dist-lib/types/document/prototype.d.ts +424 -0
  27. package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
  28. package/dist-lib/types/document/richText.d.ts +185 -0
  29. package/dist-lib/types/document/styles.d.ts +216 -0
  30. package/dist-lib/types/document/svgImport.d.ts +17 -0
  31. package/dist-lib/types/document/vector.d.ts +222 -0
  32. package/dist-lib/types/editor/designCompiler.d.ts +7 -0
  33. package/dist-lib/types/editor/designImport.d.ts +7 -0
  34. package/dist-lib/types/editor/inspect.d.ts +8 -0
  35. package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
  36. package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
  37. package/dist-lib/types/editor/store.d.ts +1 -1
  38. package/dist-lib/types/host/project.d.ts +44 -0
  39. package/dist-lib/types/host/registryConnections.d.ts +11 -0
  40. package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
  41. package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
  42. package/dist-lib/types/lib.d.ts +103 -3
  43. package/dist-lib/types/token-library/bindings.d.ts +62 -0
  44. package/dist-lib/types/token-library/contracts.d.ts +117 -0
  45. package/dist-lib/types/token-library/engine.d.ts +13 -0
  46. package/dist-lib/types/token-library/valueResolver.d.ts +19 -0
  47. package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
  48. package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
  49. package/dist-lib/types/workspace/configuration.d.ts +47 -0
  50. package/dist-lib/types/workspace/contracts.d.ts +64 -0
  51. package/dist-lib/types/workspace/controller.d.ts +35 -0
  52. package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
  53. package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
  54. package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
  55. package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
  56. package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
  57. package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
  58. package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
  59. package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
  60. package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
  61. package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
  62. package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
  63. package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
  64. package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
  65. package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
  66. package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
  67. package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
  68. package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
  69. package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
  70. package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
  71. package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
  72. package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
  73. package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
  74. package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
  75. package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
  76. package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
  77. package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
  78. package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
  79. package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
  80. package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
  81. package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
  82. package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
  83. package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
  84. package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
  85. package/dist-lib/types/workspace/source/controller.d.ts +50 -0
  86. package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
  87. package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
  88. package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
  89. package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
  90. package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
  91. package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
  92. package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
  93. package/dist-lib/types/workspace/source/structure.d.ts +21 -0
  94. package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
  95. package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
  96. package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
  97. package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
  98. package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
  99. package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
  100. package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
  101. package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
  102. package/draft-preview.d.ts +54 -0
  103. package/host-runtime.d.ts +73 -0
  104. package/inspect.d.ts +2 -0
  105. package/jsx-source-reconciler.d.ts +35 -0
  106. package/node/design-session.mjs +3 -0
  107. package/node/host-runtime.mjs +4 -0
  108. package/node/inspect-plugin.mjs +3 -1
  109. package/node/inspect-writeback.mjs +18 -2
  110. package/node/jsx-source-reconciler.mjs +155 -0
  111. package/node/preview-artifact-store.mjs +10 -7
  112. package/node/revision-catalog-resolver.mjs +159 -0
  113. package/node/source-archive-runtime.mjs +63 -0
  114. package/node/source-archive.mjs +88 -0
  115. package/node/source-bindings.mjs +65 -0
  116. package/node/source-file-lock.mjs +15 -0
  117. package/node/source-file-plan.mjs +162 -0
  118. package/node/source-file-proofs.mjs +44 -0
  119. package/node/source-module.mjs +17 -0
  120. package/node/source-responsive-codec.d.mts +17 -0
  121. package/node/source-responsive-codec.mjs +133 -0
  122. package/node/source-responsive.mjs +189 -0
  123. package/node/source-service-inputs.mjs +81 -0
  124. package/node/source-service-plugin.mjs +57 -0
  125. package/node/source-service.mjs +4 -0
  126. package/node/source-structure-reconcile.mjs +186 -0
  127. package/node/source-structure-wrapper-codec.d.mts +20 -0
  128. package/node/source-structure-wrapper-codec.mjs +205 -0
  129. package/node/source-structure.mjs +404 -0
  130. package/node/token-source-references.mjs +136 -0
  131. package/node/token-source-service.mjs +151 -0
  132. package/node/workspace-draft-entry.mjs +52 -0
  133. package/node/workspace-draft-preview-plugin.mjs +70 -0
  134. package/node/workspace-draft-preview.mjs +232 -0
  135. package/node/workspace-draft-process.mjs +129 -0
  136. package/node/workspace-draft-runtime.mjs +67 -0
  137. package/node/workspace-source-identity.d.mts +1 -0
  138. package/node/workspace-source-identity.mjs +21 -0
  139. package/node/workspace-source-plan.mjs +195 -0
  140. package/node/workspace-source-session.mjs +187 -0
  141. package/package.json +90 -6
  142. package/source-files.d.ts +35 -0
  143. package/source-module.d.ts +16 -0
  144. package/source-responsive.d.ts +10 -0
  145. package/source-service.d.ts +92 -0
  146. package/source-structure.d.ts +155 -0
  147. package/workspace-draft-entry.d.ts +15 -0
  148. package/workspace-source-plan.d.ts +44 -0
  149. package/docs/coverage-contracts.md +0 -444
  150. package/docs/screen-state-contract.md +0 -252
@@ -0,0 +1,186 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { compileJsxStructurePlan, createJsxStructureIdentity, reimportJsxStructureSource } from './source-structure.mjs';
3
+ import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
4
+
5
+ const hash = source => createHash('sha256').update(source, 'utf8').digest('hex');
6
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
7
+ const revision = value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
8
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ function freeze(value) {
10
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(freeze); Object.freeze(value); }
11
+ return value;
12
+ }
13
+ function failure(diagnostic, action = 'resolve-overlap') {
14
+ return freeze({ status: 'conflict', diagnostics: [{ ...diagnostic, action }] });
15
+ }
16
+ const layoutOf = node => node?.layout ?? null;
17
+ function shape(node) { return node ? { kind: node.kind, parentId: node.parentId, children: [...node.children], layout: layoutOf(node) } : null; }
18
+ function tree(projection) {
19
+ return new Map(projection.nodes.map(node => [node.nodeId, node]));
20
+ }
21
+ function equalTrees(left, right) {
22
+ return left.size === right.size && [...left].every(([id, node]) => same(shape(node), shape(right.get(id))));
23
+ }
24
+ function sourceResult(file, baseline, external, source, identity, projection, payload, stylesheetSource) {
25
+ const result = { status: payload ? 'ready' : 'unchanged', file,
26
+ baselineSourceRevision: baseline.sourceRevision, baselineSourceSha256: baseline.identity.sourceSha256,
27
+ sourceRevision: external.sourceRevision, externalSourceSha256: external.sha256,
28
+ source, identity, anchors: projection.anchors, nodes: projection.nodes, diagnostics: [] };
29
+ if (payload) result.payload = payload;
30
+ if (stylesheetSource !== undefined) result.stylesheetSource = stylesheetSource;
31
+ return freeze(result);
32
+ }
33
+ function updatedIdentity(file, source, rootId, anchors, opaqueNodeIds, wrapper) {
34
+ const present = new Set(anchors.map(anchor => anchor.nodeId));
35
+ return createJsxStructureIdentity({ file, source, rootId, anchors, opaqueNodeIds: opaqueNodeIds.filter(id => present.has(id)), wrapper });
36
+ }
37
+ /** Wrapper groups need the stylesheet at both revisions; its bytes are proved like the JSX. */
38
+ function wrapperInput(wrapper, file) {
39
+ if (wrapper === undefined) return { ok: true };
40
+ if (!record(wrapper) || Object.keys(wrapper).some(key => !['contract', 'baselineSource', 'externalSource', 'externalSha256'].includes(key))
41
+ || typeof wrapper.baselineSource !== 'string' || typeof wrapper.externalSource !== 'string' || !record(wrapper.contract) || typeof wrapper.contract.stylesheet !== 'string') {
42
+ return { ok: false, failure: failure({ code: 'structure-reconcile-input-invalid', message: 'Wrapper reconciliation requires the host contract and the complete baseline and latest stylesheet bytes.', file }, 'refresh-source') };
43
+ }
44
+ if (typeof wrapper.externalSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(wrapper.externalSha256) || hash(wrapper.externalSource) !== wrapper.externalSha256) {
45
+ return { ok: false, failure: failure({ code: 'structure-source-stale', message: 'The latest stylesheet bytes no longer match their supplied SHA-256 proof. Read and review the latest source again.', file: wrapper.contract.stylesheet }, 'refresh-source') };
46
+ }
47
+ return { ok: true };
48
+ }
49
+ const changedFile = (compiled, file, fallback) => compiled.status === 'ready' ? compiled.payload.changes.find(change => change.file === file)?.content ?? fallback : fallback;
50
+
51
+ /**
52
+ * Three-way structure reconciliation with explicit host-supplied current-source identity.
53
+ * IDs are never inferred from snippets, names, runtime values, or positional similarity.
54
+ * Only a new plan against the external bytes is returned; baseline proofs are never relabeled.
55
+ */
56
+ export function reconcileJsxStructurePlan(input) {
57
+ let captured;
58
+ try { captured = JSON.parse(workspaceSourceFingerprint(input)); }
59
+ catch { return failure({ code: 'structure-reconcile-input-invalid', message: 'Reconciliation requires complete serializable source, identity, and draft operation captures.' }, 'refresh-source'); }
60
+ const { file, baseline, external, operations, allowedFiles, excludedSourcePrefixes = [], wrapper } = captured ?? {};
61
+ if (!record(baseline) || !record(external) || !revision(baseline.sourceRevision) || !revision(external.sourceRevision)
62
+ || typeof baseline.source !== 'string' || typeof external.source !== 'string' || !Array.isArray(operations)) {
63
+ return failure({ code: 'structure-reconcile-input-invalid', message: 'Provide both exact source revisions, complete source bytes, and the audited draft structure operations.', ...(typeof file === 'string' ? { file } : {}) }, 'refresh-source');
64
+ }
65
+ if (!baseline.identity || !external.identity) {
66
+ return failure({ code: 'structure-reconcile-identity-required', message: 'Provide complete baseline and external identity tables. Explicitly map retained IDs, allocate new IDs, and omit only deleted nodes.', file }, 'map-identities');
67
+ }
68
+ if (typeof external.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(external.sha256) || hash(external.source) !== external.sha256) {
69
+ return failure({ code: 'structure-source-stale', message: 'The latest source bytes no longer match their supplied SHA-256 proof. Read and review the latest source again.', file }, 'refresh-source');
70
+ }
71
+ const checked = wrapperInput(wrapper, file);
72
+ if (!checked.ok) return checked.failure;
73
+ const evidence = source => wrapper ? { contract: wrapper.contract, source } : undefined;
74
+ const stylesheetFiles = source => wrapper ? [{ file: wrapper.contract.stylesheet, source, sha256: hash(source) }] : [];
75
+ const contract = wrapper ? { wrapper: wrapper.contract } : {};
76
+ let before; let current;
77
+ try {
78
+ before = reimportJsxStructureSource({ file, source: baseline.source, identity: baseline.identity, wrapper: evidence(wrapper?.baselineSource) });
79
+ current = reimportJsxStructureSource({ file, source: external.source, identity: external.identity, wrapper: evidence(wrapper?.externalSource) });
80
+ } catch (error) {
81
+ return failure(error?.diagnostic ?? { code: 'structure-reconcile-identity-invalid', message: error instanceof Error ? error.message : String(error), file },
82
+ error?.diagnostic?.code?.includes('stale') ? 'refresh-source' : 'map-identities');
83
+ }
84
+ const originalNodes = tree(before); const externalNodes = tree(current);
85
+ if (baseline.identity.rootId !== external.identity.rootId) {
86
+ return failure({ code: 'structure-reconcile-root-conflict', message: 'The external identity must explicitly retain the source island root ID. Select and map the corresponding root before reconciling.', file, nodeId: baseline.identity.rootId }, 'map-identities');
87
+ }
88
+ const originalOpaque = new Set(baseline.identity.opaqueNodeIds); const externalOpaque = new Set(external.identity.opaqueNodeIds);
89
+ const identityConflicts = [];
90
+ for (const [id, node] of originalNodes) {
91
+ const latest = externalNodes.get(id);
92
+ if (latest && (node.kind !== latest.kind || originalOpaque.has(id) !== externalOpaque.has(id))) {
93
+ identityConflicts.push({ code: 'structure-reconcile-boundary-conflict', message: 'A retained ID changed node kind or opaque boundary. Confirm its source ownership and explicit identity mapping.', file, nodeId: id,
94
+ action: 'map-identities', baseline: shape(node), external: shape(latest) });
95
+ }
96
+ }
97
+ if (identityConflicts.length) return freeze({ status: 'conflict', diagnostics: identityConflicts });
98
+
99
+ let draftCompiled;
100
+ try {
101
+ draftCompiled = compileJsxStructurePlan({ sourceRevision: baseline.sourceRevision,
102
+ files: [{ file, source: baseline.source, sha256: baseline.identity.sourceSha256 }, ...stylesheetFiles(wrapper?.baselineSource)],
103
+ anchors: before.anchors, operations, allowedFiles, excludedSourcePrefixes, ...contract });
104
+ } catch (error) {
105
+ return failure({ code: 'structure-reconcile-draft-invalid', message: error instanceof Error ? error.message : String(error), file });
106
+ }
107
+ if (draftCompiled.status === 'blocked') return freeze({ status: 'conflict', diagnostics: draftCompiled.diagnostics.map(diagnostic => ({ ...diagnostic, action: 'resolve-overlap' })) });
108
+ const draftSource = changedFile(draftCompiled, file, baseline.source);
109
+ const draftStylesheet = wrapper ? changedFile(draftCompiled, wrapper.contract.stylesheet, wrapper.baselineSource) : undefined;
110
+ let draftIdentity; let draft;
111
+ try {
112
+ draftIdentity = updatedIdentity(file, draftSource, baseline.identity.rootId, draftCompiled.anchors, baseline.identity.opaqueNodeIds, evidence(draftStylesheet));
113
+ draft = reimportJsxStructureSource({ file, source: draftSource, identity: draftIdentity, wrapper: evidence(draftStylesheet) });
114
+ } catch (error) {
115
+ return failure(error?.diagnostic ?? { code: 'structure-reconcile-draft-invalid', message: error instanceof Error ? error.message : String(error), file });
116
+ }
117
+ const draftNodes = tree(draft);
118
+ // An unchanged draft accepts the explicitly mapped latest tree. If the entire draft tree is
119
+ // already present externally, keep its latest bytes without replaying groups or moves twice.
120
+ if (draftCompiled.status === 'unchanged' || equalTrees(draftNodes, externalNodes)) {
121
+ return sourceResult(file, baseline, external, external.source, external.identity, current, undefined, wrapper?.externalSource);
122
+ }
123
+ const conflicts = [];
124
+ const conflict = (code, message, id) => conflicts.push({ code, message, file, nodeId: id, action: 'resolve-overlap',
125
+ baseline: shape(originalNodes.get(id)), external: shape(externalNodes.get(id)), draft: shape(draftNodes.get(id)) });
126
+ for (const [id, original] of originalNodes) {
127
+ const latest = externalNodes.get(id); const desired = draftNodes.get(id);
128
+ if (!desired) {
129
+ if (latest && (!same(original.children, latest.children) || original.parentId !== latest.parentId)) {
130
+ conflict('structure-reconcile-delete-conflict', 'The draft removes a node whose external parent or children changed. Resolve that removal against the latest hierarchy.', id);
131
+ }
132
+ } else if (!latest) {
133
+ if (!same(original.children, desired.children) || original.parentId !== desired.parentId) {
134
+ conflict('structure-reconcile-missing-node', 'An externally deleted node is still changed by the draft. Restore its explicit identity or revise the draft move.', id);
135
+ }
136
+ } else {
137
+ if (!same(original.children, desired.children) && !same(original.children, latest.children)) {
138
+ conflict('structure-reconcile-children-conflict', 'Both sources changed this direct-child list. Choose an explicit final order before reconciling.', id);
139
+ }
140
+ if (original.parentId !== desired.parentId && original.parentId !== latest.parentId) {
141
+ conflict('structure-reconcile-parent-conflict', 'Both sources moved this node. Choose its final parent before reconciling.', id);
142
+ }
143
+ if (!same(layoutOf(original), layoutOf(desired)) && !same(layoutOf(original), layoutOf(latest))) {
144
+ conflict('structure-reconcile-layout-conflict', 'Both sources changed this wrapper layout. Choose its final layout before reconciling.', id);
145
+ }
146
+ }
147
+ }
148
+ for (const id of draftNodes.keys()) {
149
+ if (!originalNodes.has(id) && externalNodes.has(id)) conflict('structure-reconcile-new-id-conflict', 'The draft and external source both introduced this ID. Map their identities or allocate a distinct draft ID.', id);
150
+ }
151
+ if (conflicts.length) return freeze({ status: 'conflict', diagnostics: conflicts });
152
+
153
+ let merged;
154
+ try {
155
+ merged = compileJsxStructurePlan({ sourceRevision: external.sourceRevision,
156
+ files: [{ file, source: external.source, sha256: external.sha256 }, ...stylesheetFiles(wrapper?.externalSource)], anchors: current.anchors,
157
+ operations, allowedFiles, excludedSourcePrefixes, ...contract });
158
+ } catch (error) {
159
+ return failure({ code: 'structure-reconcile-merge-invalid', message: error instanceof Error ? error.message : String(error), file });
160
+ }
161
+ if (merged.status === 'blocked') return freeze({ status: 'conflict', diagnostics: merged.diagnostics.map(diagnostic => ({ ...diagnostic, action: 'resolve-overlap' })) });
162
+ const source = changedFile(merged, file, external.source);
163
+ const stylesheetSource = wrapper ? changedFile(merged, wrapper.contract.stylesheet, wrapper.externalSource) : undefined;
164
+ let mergedIdentity; let projection;
165
+ try {
166
+ mergedIdentity = updatedIdentity(file, source, external.identity.rootId, merged.anchors, external.identity.opaqueNodeIds, evidence(stylesheetSource));
167
+ projection = reimportJsxStructureSource({ file, source, identity: mergedIdentity, wrapper: evidence(stylesheetSource) });
168
+ } catch (error) {
169
+ return failure(error?.diagnostic ?? { code: 'structure-reconcile-merge-invalid', message: error instanceof Error ? error.message : String(error), file });
170
+ }
171
+ const mergedNodes = tree(projection);
172
+ const expected = new Map();
173
+ for (const [id, node] of externalNodes) {
174
+ const original = originalNodes.get(id); const desired = draftNodes.get(id);
175
+ if (original && !desired) continue;
176
+ expected.set(id, { ...node,
177
+ parentId: original && desired.parentId !== original.parentId ? desired.parentId : node.parentId,
178
+ children: original && !same(desired.children, original.children) ? desired.children : node.children,
179
+ layout: original && !same(layoutOf(desired), layoutOf(original)) ? layoutOf(desired) : layoutOf(node) });
180
+ }
181
+ for (const [id, node] of draftNodes) if (!originalNodes.has(id)) expected.set(id, node);
182
+ if (!equalTrees(expected, mergedNodes)) {
183
+ return failure({ code: 'structure-reconcile-result-conflict', message: 'The replayed operations do not match the combined explicit hierarchies. Resolve the final structure before applying.', file });
184
+ }
185
+ return sourceResult(file, baseline, external, source, mergedIdentity, projection, merged.status === 'ready' ? merged.payload : undefined, stylesheetSource);
186
+ }
@@ -0,0 +1,20 @@
1
+ import type { WorkspaceStructureGroupLayout, WorkspaceStructureWrapperDeclaration } from '../src/workspace/source/contracts';
2
+
3
+ export interface WrapperBlock { start: number; end: number; declarations: Record<string, string> }
4
+ export const VOID_ELEMENTS: ReadonlySet<string>;
5
+ export const WRAPPER_ALIGNMENTS: readonly ['start', 'center', 'end', 'stretch'];
6
+ export function wrapperContractProblem(contract: unknown): string | null;
7
+ export function wrapperTagName(contract: WorkspaceStructureWrapperDeclaration): string;
8
+ export function wrapperOpening(contract: WorkspaceStructureWrapperDeclaration, className: string): string;
9
+ export function wrapperClosing(contract: WorkspaceStructureWrapperDeclaration): string;
10
+ export function wrapperMarker(groupId: string): string;
11
+ export function wrapperClassName(groupId: string): string | null;
12
+ export function wrapperLayoutProblem(layout: unknown, contract: WorkspaceStructureWrapperDeclaration): string | null;
13
+ export function wrapperLayout(layout: WorkspaceStructureGroupLayout & { sizing?: string; crossSizing?: string }): WorkspaceStructureGroupLayout;
14
+ export function wrapperDeclarations(layout: WorkspaceStructureGroupLayout, contract: WorkspaceStructureWrapperDeclaration): Record<string, string>;
15
+ export function wrapperLayoutFromDeclarations(declarations: Record<string, string>, contract: WorkspaceStructureWrapperDeclaration): WorkspaceStructureGroupLayout;
16
+ export function wrapperBlock(groupId: string, className: string, layout: WorkspaceStructureGroupLayout, contract: WorkspaceStructureWrapperDeclaration, eol?: string): string;
17
+ export function findWrapperBlock(source: string, groupId: string, className: string): WrapperBlock | null;
18
+ export function wrapperClassReferenced(source: string, className: string, ignore?: readonly { start: number; end: number }[]): boolean;
19
+ export function readWrapperLayout(source: string, groupId: string, className: string, contract: WorkspaceStructureWrapperDeclaration): WorkspaceStructureGroupLayout | null;
20
+ export function applyWrapperEdits(source: string, edits: ReadonlyMap<string, WorkspaceStructureGroupLayout | null>, contract: WorkspaceStructureWrapperDeclaration): string;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pure wrapper codec shared by the browser document compiler and the Node source compiler.
3
+ *
4
+ * A source group with a layout compiles to one intrinsic element carrying a generated CSS
5
+ * module class. This module owns the deterministic class name, the layout-to-declaration
6
+ * mapping, and the exact managed block syntax. Spacing spellings come from the host
7
+ * contract; nothing here reads files, evaluates Sass, or knows a product token.
8
+ */
9
+ export const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
10
+ export const WRAPPER_ALIGNMENTS = Object.freeze(['start', 'center', 'end', 'stretch']);
11
+ const FLEX_ALIGN = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' };
12
+ const FLEX_DIRECTION = { horizontal: 'row', vertical: 'column' };
13
+ const MEASURES = { crossGap: null, paddingTop: 'padding-top', paddingRight: 'padding-right', paddingBottom: 'padding-bottom', paddingLeft: 'padding-left', minWidth: 'min-width', maxWidth: 'max-width', minHeight: 'min-height', maxHeight: 'max-height' };
14
+ const DISTRIBUTION = { start: 'flex-start', center: 'center', end: 'flex-end', 'space-between': 'space-between' };
15
+ const SOURCE_FIELDS = ['direction', 'gap', 'padding', 'align', 'wrap', 'crossGap', 'distribute', 'columns', ...Object.keys(MEASURES).filter(key => key !== 'crossGap')];
16
+ const MANAGED_PROPERTIES = ['display', 'flex-direction', 'grid-template-columns', 'flex-wrap', 'gap', 'row-gap', 'column-gap', 'padding', 'align-items', 'justify-content', 'justify-items', ...Object.values(MEASURES).filter(Boolean)];
17
+
18
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
19
+ const identifier = value => typeof value === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(value);
20
+ const cssValue = value => typeof value === 'string' && value.length > 0 && value.length <= 160 && value === value.trim()
21
+ && !/[;{}\0\r\n]/.test(value) && !/\/\*|\*\/|\/\//.test(value);
22
+ const stylesheetPath = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && /\.s?css$/i.test(value)
23
+ && !value.startsWith('/') && !/^[a-z]:/i.test(value) && !/[\\\x00-\x1f\x7f?#]/.test(value)
24
+ && !value.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
25
+ const escape = value => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
26
+ const lineEnding = source => source.includes('\r\n') ? '\r\n' : '\n';
27
+ const problem = (code, message) => Object.assign(new Error(message), { code });
28
+ const stale = message => problem('structure-wrapper-stale', message);
29
+ const masked = source => source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, value => value.replace(/[^\n]/g, ' '));
30
+
31
+ /** Returns null when the contract is usable, otherwise one explicit reason. */
32
+ export function wrapperContractProblem(contract) {
33
+ if (!record(contract) || Object.keys(contract).some(key => !['stylesheet', 'styleAlias', 'tagName', 'spacing', 'align'].includes(key))) {
34
+ return 'A wrapper contract declares a stylesheet, a style alias, permitted spacing, and optionally a tag name and alignments.';
35
+ }
36
+ if (!stylesheetPath(contract.stylesheet)) return 'The wrapper stylesheet must be a normalized relative CSS or SCSS path.';
37
+ if (!identifier(contract.styleAlias)) return 'The wrapper style alias must be the JSX identifier of the imported stylesheet module.';
38
+ if (contract.tagName !== undefined && (typeof contract.tagName !== 'string' || !/^[a-z][a-z0-9]*$/.test(contract.tagName) || VOID_ELEMENTS.has(contract.tagName))) {
39
+ return 'The wrapper tag must be an intrinsic non-void element.';
40
+ }
41
+ if (!Array.isArray(contract.spacing) || !contract.spacing.length || contract.spacing.length > 200) return 'A wrapper contract lists at least one permitted spacing value.';
42
+ const px = new Set(); const css = new Set();
43
+ for (const entry of contract.spacing) {
44
+ if (!record(entry) || Object.keys(entry).some(key => !['px', 'css'].includes(key)) || typeof entry.px !== 'number' || !Number.isFinite(entry.px)
45
+ || entry.px < 0 || !cssValue(entry.css) || px.has(entry.px) || css.has(entry.css)) {
46
+ return 'Each permitted spacing pairs one nonnegative pixel value with one unique CSS spelling.';
47
+ }
48
+ px.add(entry.px); css.add(entry.css);
49
+ }
50
+ if (contract.align !== undefined && (!Array.isArray(contract.align) || !contract.align.length || new Set(contract.align).size !== contract.align.length
51
+ || contract.align.some(value => !WRAPPER_ALIGNMENTS.includes(value)))) {
52
+ return 'Permitted wrapper alignments must be a unique subset of start, center, end, and stretch.';
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export const wrapperTagName = contract => contract.tagName ?? 'div';
58
+ export const wrapperOpening = (contract, className) => `<${wrapperTagName(contract)} className={${contract.styleAlias}.${className}}>`;
59
+ export const wrapperClosing = contract => `</${wrapperTagName(contract)}>`;
60
+ export const wrapperMarker = groupId => `/* Pygmalion wrapper: ${groupId} */`;
61
+
62
+ /** Deterministic CSS module identifier for one stable group ID; null when the ID cannot derive one. */
63
+ export function wrapperClassName(groupId) {
64
+ if (typeof groupId !== 'string' || groupId.length > 120 || !/^[A-Za-z0-9][\w:./-]*$/.test(groupId)) return null;
65
+ const words = groupId.split(/[^A-Za-z0-9]+/).filter(Boolean);
66
+ const name = `wrap${words.map(word => word[0].toUpperCase() + word.slice(1)).join('')}`;
67
+ return name.length <= 64 ? name : null;
68
+ }
69
+
70
+ /** Returns null for a layout the contract can express, otherwise one explicit reason. */
71
+ export function wrapperLayoutProblem(layout, contract) {
72
+ if (!record(layout) || Object.keys(layout).some(key => ![...SOURCE_FIELDS, 'sizing', 'crossSizing'].includes(key))) return 'A wrapper layout uses only supported source layout fields.';
73
+ if (!Object.hasOwn(FLEX_DIRECTION, layout.direction) && layout.direction !== 'grid') return 'A wrapper layout direction is horizontal, vertical, or grid.';
74
+ for (const property of ['gap', 'padding', ...Object.keys(MEASURES)]) {
75
+ if ((property === 'gap' || property === 'padding' || layout[property] !== undefined) && !contract.spacing.some(entry => entry.px === layout[property])) return `The wrapper ${property} must be one of the permitted spacing values.`;
76
+ }
77
+ if (layout.align !== undefined && !(contract.align ?? WRAPPER_ALIGNMENTS).includes(layout.align)) return 'The wrapper alignment must be one of the permitted alignments.';
78
+ if (['sizing', 'crossSizing'].some(key => layout[key] !== undefined && layout[key] !== 'fixed')) return 'A wrapper element sizes by the application layout; hug sizing is not a source value.';
79
+ if (layout.wrap !== undefined && (typeof layout.wrap !== 'boolean' || layout.direction === 'grid')) return 'Wrapping is a boolean for flex wrappers only.';
80
+ if (layout.columns !== undefined && (layout.direction !== 'grid' || !Number.isInteger(layout.columns) || layout.columns < 1 || layout.columns > 1000)) return 'Grid columns must be an integer from 1 to 1000 on a grid wrapper.';
81
+ if (layout.distribute !== undefined && (!Object.hasOwn(DISTRIBUTION, layout.distribute) || layout.direction === 'grid' && layout.distribute === 'space-between')) return 'Invalid wrapper distribution.';
82
+ for (const axis of ['Width', 'Height']) if (layout[`min${axis}`] !== undefined && layout[`max${axis}`] !== undefined && layout[`min${axis}`] > layout[`max${axis}`]) return 'A wrapper minimum cannot exceed its maximum.';
83
+ return null;
84
+ }
85
+
86
+ /** The source-relevant fields; fixed authoring sizing is not emitted. */
87
+ export function wrapperLayout(layout) {
88
+ return Object.fromEntries(SOURCE_FIELDS.filter(key => layout[key] !== undefined || key === 'columns' && layout.direction === 'grid').map(key => [key, key === 'columns' ? layout.columns ?? 2 : layout[key]]));
89
+ }
90
+
91
+ export function wrapperDeclarations(layout, contract) {
92
+ const reason = wrapperLayoutProblem(layout, contract);
93
+ if (reason) throw problem('structure-layout-invalid', reason);
94
+ const spacing = px => contract.spacing.find(entry => entry.px === px).css;
95
+ const grid = layout.direction === 'grid';
96
+ const declarations = { display: grid ? 'grid' : 'flex', ...(grid ? { 'grid-template-columns': `repeat(${layout.columns ?? 2}, minmax(0, 1fr))` } : { 'flex-direction': FLEX_DIRECTION[layout.direction] }), gap: spacing(layout.gap), padding: spacing(layout.padding), ...(layout.align === undefined ? {} : { 'align-items': FLEX_ALIGN[layout.align] }) };
97
+ if (layout.wrap !== undefined) declarations['flex-wrap'] = layout.wrap ? 'wrap' : 'nowrap';
98
+ if (layout.distribute !== undefined) declarations[grid ? 'justify-items' : 'justify-content'] = DISTRIBUTION[layout.distribute];
99
+ for (const [key, css] of Object.entries(MEASURES)) if (layout[key] !== undefined) declarations[css ?? (layout.direction === 'vertical' ? 'column-gap' : 'row-gap')] = spacing(layout[key]);
100
+ return declarations;
101
+ }
102
+
103
+ /** Every accepted source value must decode through the host's exact token spelling. */
104
+ export function wrapperLayoutFromDeclarations(declarations, contract) {
105
+ const keys = Object.keys(declarations);
106
+ const grid = declarations.display === 'grid';
107
+ if ((!grid && declarations.display !== 'flex') || keys.some(key => !MANAGED_PROPERTIES.includes(key)) || !['gap', 'padding', grid ? 'grid-template-columns' : 'flex-direction'].every(key => keys.includes(key))) throw stale('A managed wrapper block needs its supported display, direction, gap, and padding.');
108
+ if (grid && ('flex-direction' in declarations || 'flex-wrap' in declarations || 'justify-content' in declarations) || !grid && ('grid-template-columns' in declarations || 'justify-items' in declarations)) throw stale('Managed wrapper display and alignment fields must agree.');
109
+ const direction = grid ? 'grid' : Object.keys(FLEX_DIRECTION).find(key => FLEX_DIRECTION[key] === declarations['flex-direction']);
110
+ const spacing = css => contract.spacing.find(entry => entry.css === css)?.px;
111
+ const result = { direction, gap: spacing(declarations.gap), padding: spacing(declarations.padding) };
112
+ if (declarations['align-items'] !== undefined) result.align = Object.keys(FLEX_ALIGN).find(key => FLEX_ALIGN[key] === declarations['align-items']);
113
+ if ('align-items' in declarations && result.align === undefined) throw stale('Unknown managed alignment.');
114
+ if (grid) {
115
+ const match = /^repeat\(([1-9][0-9]*), minmax\(0, 1fr\)\)$/.exec(declarations['grid-template-columns']);
116
+ if (!match) throw stale('Grid tracks must use the generated equal-column form.');
117
+ result.columns = Number(match[1]);
118
+ }
119
+ if ('flex-wrap' in declarations) {
120
+ if (!['wrap', 'nowrap'].includes(declarations['flex-wrap'])) throw stale('Unknown managed wrapping.');
121
+ result.wrap = declarations['flex-wrap'] === 'wrap';
122
+ }
123
+ const justify = grid ? 'justify-items' : 'justify-content';
124
+ if (justify in declarations) {
125
+ result.distribute = Object.keys(DISTRIBUTION).find(key => DISTRIBUTION[key] === declarations[justify]);
126
+ if (!result.distribute) throw stale('Unknown managed distribution.');
127
+ }
128
+ const crossGap = direction === 'vertical' ? 'column-gap' : 'row-gap';
129
+ if (declarations[direction === 'vertical' ? 'row-gap' : 'column-gap'] !== undefined) throw stale('The cross gap must match the wrapper direction.');
130
+ for (const [key, css] of Object.entries(MEASURES)) {
131
+ const name = css ?? crossGap;
132
+ if (name in declarations) {
133
+ result[key] = spacing(declarations[name]);
134
+ if (result[key] === undefined) throw stale('A managed measure must use the exact permitted spacing spelling.');
135
+ }
136
+ }
137
+ const reason = wrapperLayoutProblem(result, contract);
138
+ if (reason) throw stale(reason);
139
+ return wrapperLayout(result);
140
+ }
141
+
142
+ export function wrapperBlock(groupId, className, layout, contract, eol = '\n') {
143
+ const lines = Object.entries(wrapperDeclarations(layout, contract)).map(([property, value]) => ` ${property}: ${value};`);
144
+ return [wrapperMarker(groupId), `.${className} {`, ...lines, '}'].join(eol);
145
+ }
146
+
147
+ /** Locates the one managed block for a group. Null when absent; throws when present but not canonical. */
148
+ export function findWrapperBlock(source, groupId, className) {
149
+ const marker = wrapperMarker(groupId);
150
+ const start = source.indexOf(marker);
151
+ if (start < 0) return null;
152
+ if (source.indexOf(marker, start + marker.length) >= 0) throw stale(`The managed wrapper block for ${groupId} must be unique.`);
153
+ if (start > 0 && source[start - 1] !== '\n') throw stale(`The managed wrapper block for ${groupId} must start its own line.`);
154
+ const pattern = new RegExp(`^\\r?\\n\\.${escape(className)} \\{\\r?\\n((?: [a-z-]+: [^;{}\\r\\n]+;\\r?\\n){1,20})\\}(?=\\r?\\n|$)`);
155
+ const match = pattern.exec(source.slice(start + marker.length));
156
+ if (!match) throw stale(`The managed wrapper block for ${groupId} was edited outside its generated form.`);
157
+ const declarations = {};
158
+ for (const line of match[1].split(/\r?\n/).filter(Boolean)) {
159
+ const [, property, value] = /^ {2}([a-z-]+): (.+);$/.exec(line);
160
+ if (Object.hasOwn(declarations, property)) throw stale(`The managed wrapper block for ${groupId} repeats a declaration.`);
161
+ declarations[property] = value;
162
+ }
163
+ return { start, end: start + marker.length + match[0].length, declarations };
164
+ }
165
+
166
+ /** True when a selector outside the ignored ranges references the class. Comments and strings are masked. */
167
+ export function wrapperClassReferenced(source, className, ignore = []) {
168
+ let text = masked(source);
169
+ for (const range of ignore) text = text.slice(0, range.start) + ' '.repeat(range.end - range.start) + text.slice(range.end);
170
+ return new RegExp(`\\.${escape(className)}(?![\\w-])`).test(text);
171
+ }
172
+
173
+ /** Layout of the managed block for a group, or null when the stylesheet has none. Throws on any unowned edit. */
174
+ export function readWrapperLayout(source, groupId, className, contract) {
175
+ const block = findWrapperBlock(source, groupId, className);
176
+ if (!block) return null;
177
+ if (wrapperClassReferenced(source, className, [block])) throw stale(`Another rule also styles .${className}; the wrapper block is not the only owner.`);
178
+ return wrapperLayoutFromDeclarations(block.declarations, contract);
179
+ }
180
+
181
+ /** Applies removals (null) and in-place or appended blocks in order; the input string is never mutated. */
182
+ export function applyWrapperEdits(source, edits, contract) {
183
+ const eol = lineEnding(source);
184
+ let content = source;
185
+ for (const [groupId, layout] of edits) {
186
+ const className = wrapperClassName(groupId);
187
+ if (!className) throw problem('structure-group-invalid', 'A wrapper group identity must derive a CSS module class name.');
188
+ const existing = findWrapperBlock(content, groupId, className);
189
+ if (layout === null) {
190
+ if (!existing) throw stale(`The managed wrapper block for ${groupId} is missing.`);
191
+ let { start, end } = existing;
192
+ if (content.startsWith(eol, end)) end += eol.length;
193
+ if (start >= 2 * eol.length && content.slice(start - 2 * eol.length, start) === eol + eol) start -= eol.length;
194
+ content = content.slice(0, start) + content.slice(end);
195
+ continue;
196
+ }
197
+ if (wrapperClassReferenced(content, className, existing ? [existing] : [])) {
198
+ throw problem('structure-wrapper-class-collision', `The stylesheet already styles .${className}; choose another group identity.`);
199
+ }
200
+ const block = wrapperBlock(groupId, className, layout, contract, eol);
201
+ if (existing) { content = content.slice(0, existing.start) + block + content.slice(existing.end); continue; }
202
+ content = content.length ? `${content}${content.endsWith(eol) ? '' : eol}${eol}${block}${eol}` : `${block}${eol}`;
203
+ }
204
+ return content;
205
+ }