@smallpen/core 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -0
- package/src/canonical.mjs +64 -0
- package/src/capabilities.mjs +495 -0
- package/src/catalog.mjs +362 -0
- package/src/component-samples.mjs +84 -0
- package/src/components-domain.mjs +335 -0
- package/src/contexts.mjs +248 -0
- package/src/design-projection.mjs +657 -0
- package/src/design-read.mjs +825 -0
- package/src/design-system-authoring.mjs +102 -0
- package/src/design-system-canvas.mjs +862 -0
- package/src/design-system.mjs +437 -0
- package/src/design-validation.mjs +324 -0
- package/src/draft.mjs +784 -0
- package/src/effective-tokens.mjs +377 -0
- package/src/errors.mjs +12 -0
- package/src/index.mjs +112 -0
- package/src/initialization.mjs +310 -0
- package/src/package.mjs +5935 -0
- package/src/projection-values.mjs +138 -0
- package/src/requirements-domain.mjs +222 -0
- package/src/scenarios-domain.mjs +268 -0
- package/src/token-advice.mjs +272 -0
- package/src/token-import.mjs +607 -0
- package/src/tokens-domain.mjs +410 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
// DSC-004: canvas scene data protocol.
|
|
2
|
+
//
|
|
3
|
+
// The canvas scene is a generated projection: it never enters canonical
|
|
4
|
+
// package data. Scene node ids are derived from source identities and are
|
|
5
|
+
// stable across rebuilds of the same source+combination; they are NOT
|
|
6
|
+
// canonical shape ids. Every scene node carries a sourceRef that states its
|
|
7
|
+
// unique write target (or decoration, which has none) plus the fields that
|
|
8
|
+
// editing may address.
|
|
9
|
+
//
|
|
10
|
+
// Terminology (renderer-contract.md): scene ids are prefixed `scn/` and are
|
|
11
|
+
// the only ids the canvas UI addresses; canonical ids only ever appear
|
|
12
|
+
// inside sourceRef.
|
|
13
|
+
import { tokenInventoryRows } from "./catalog.mjs";
|
|
14
|
+
|
|
15
|
+
export const CANVAS_SCENE_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
const CANONICAL_OVERRIDE_FIELDS = [
|
|
18
|
+
"fills",
|
|
19
|
+
"name",
|
|
20
|
+
"opacity",
|
|
21
|
+
"text",
|
|
22
|
+
"visible",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function digest(input) {
|
|
26
|
+
// FNV-1a, matching the deterministic digest style of the token inventory:
|
|
27
|
+
// no Node built-ins, stable across runs.
|
|
28
|
+
let hash = 0x811c9dc5;
|
|
29
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
30
|
+
hash ^= input.charCodeAt(index);
|
|
31
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
32
|
+
}
|
|
33
|
+
let hash2 = 0x811c9dc5;
|
|
34
|
+
for (let index = input.length - 1; index >= 0; index -= 1) {
|
|
35
|
+
hash2 ^= input.charCodeAt(index);
|
|
36
|
+
hash2 = Math.imul(hash2, 0x01000193) >>> 0;
|
|
37
|
+
}
|
|
38
|
+
return `${hash.toString(16).padStart(8, "0")}${hash2
|
|
39
|
+
.toString(16)
|
|
40
|
+
.padStart(8, "0")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sceneNodeId(...parts) {
|
|
44
|
+
return `scn/${parts.map((part) => encodeURIComponent(part)).join("/")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sceneNodeIdBoard(name) {
|
|
48
|
+
return sceneNodeId("board", name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nodeEditableFields(node) {
|
|
52
|
+
if (!node) return [];
|
|
53
|
+
const fields = [];
|
|
54
|
+
if (node.text !== undefined) fields.push("text");
|
|
55
|
+
if (node.fills !== undefined) fields.push("fills");
|
|
56
|
+
if (node.opacity !== undefined) fields.push("opacity");
|
|
57
|
+
if (node.cornerRadius !== undefined) fields.push("cornerRadius");
|
|
58
|
+
if (node.width !== undefined) fields.push("width");
|
|
59
|
+
if (node.height !== undefined) fields.push("height");
|
|
60
|
+
return fields;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function componentSources(snapshot, options) {
|
|
64
|
+
return [
|
|
65
|
+
{ snapshot, owner: snapshot.manifest.packageId, kind: "product" },
|
|
66
|
+
...(options.foundation
|
|
67
|
+
? [
|
|
68
|
+
{
|
|
69
|
+
snapshot: options.foundation,
|
|
70
|
+
owner: options.foundation.manifest.packageId,
|
|
71
|
+
kind: "foundation",
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
: []),
|
|
75
|
+
...(options.libraries ?? []).map((library) => ({
|
|
76
|
+
snapshot: library,
|
|
77
|
+
owner: library.manifest.packageId,
|
|
78
|
+
kind: "library",
|
|
79
|
+
})),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function componentVariantSceneNodes(owner, componentSet, variant, sink) {
|
|
84
|
+
let rootId = null;
|
|
85
|
+
const walk = (sourceNodeId, parentSceneId, definitionNodeId) => {
|
|
86
|
+
const node = variant.nodes[definitionNodeId];
|
|
87
|
+
const id = sceneNodeId("cmp", owner, componentSet.id, variant.id, sourceNodeId);
|
|
88
|
+
sink[id] = {
|
|
89
|
+
bounds: {
|
|
90
|
+
height: node?.height ?? 0,
|
|
91
|
+
width: node?.width ?? 0,
|
|
92
|
+
x: node?.x ?? 0,
|
|
93
|
+
y: node?.y ?? 0,
|
|
94
|
+
},
|
|
95
|
+
children: (node?.children ?? []).map((childId) =>
|
|
96
|
+
sceneNodeId("cmp", owner, componentSet.id, variant.id, childId),
|
|
97
|
+
),
|
|
98
|
+
decoration: false,
|
|
99
|
+
editableFields: nodeEditableFields(node),
|
|
100
|
+
id,
|
|
101
|
+
kind: "node",
|
|
102
|
+
label: node?.name ?? definitionNodeId,
|
|
103
|
+
parent: parentSceneId,
|
|
104
|
+
sourceRef: {
|
|
105
|
+
componentId: componentSet.id,
|
|
106
|
+
kind: "component-definition",
|
|
107
|
+
ownerPackageId: owner,
|
|
108
|
+
sourceNodeId: definitionNodeId,
|
|
109
|
+
variantId: variant.id,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
for (const childId of node?.children ?? []) {
|
|
113
|
+
walk(childId, id, childId);
|
|
114
|
+
}
|
|
115
|
+
return id;
|
|
116
|
+
};
|
|
117
|
+
rootId = walk(variant.rootId, null, variant.rootId);
|
|
118
|
+
return rootId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pageSceneNodes(owner, screen, presentation, sink) {
|
|
122
|
+
const pageId = sceneNodeId("page", owner, screen.id);
|
|
123
|
+
const walk = (sourceNodeId, parentSceneId) => {
|
|
124
|
+
const node = presentation.nodes[sourceNodeId];
|
|
125
|
+
if (!node) return null;
|
|
126
|
+
const isInstance = node.type === "INSTANCE";
|
|
127
|
+
const id = isInstance
|
|
128
|
+
? sceneNodeId("inst", owner, screen.id, sourceNodeId)
|
|
129
|
+
: sceneNodeId("pgnode", owner, screen.id, sourceNodeId);
|
|
130
|
+
const children = [];
|
|
131
|
+
for (const childId of node.children ?? []) {
|
|
132
|
+
const childSceneId = walk(childId, id);
|
|
133
|
+
if (childSceneId) children.push(childSceneId);
|
|
134
|
+
}
|
|
135
|
+
const editable = isInstance
|
|
136
|
+
? CANONICAL_OVERRIDE_FIELDS.filter((field) =>
|
|
137
|
+
Object.keys(node).includes(field),
|
|
138
|
+
)
|
|
139
|
+
: nodeEditableFields(node);
|
|
140
|
+
sink[id] = {
|
|
141
|
+
bounds: {
|
|
142
|
+
height: node.height ?? 0,
|
|
143
|
+
width: node.width ?? 0,
|
|
144
|
+
x: node.x ?? 0,
|
|
145
|
+
y: node.y ?? 0,
|
|
146
|
+
},
|
|
147
|
+
children,
|
|
148
|
+
decoration: false,
|
|
149
|
+
editableFields: editable,
|
|
150
|
+
id,
|
|
151
|
+
kind: isInstance ? "page-occurrence" : "page-node",
|
|
152
|
+
label: node.name ?? sourceNodeId,
|
|
153
|
+
parent: parentSceneId,
|
|
154
|
+
sourceRef: {
|
|
155
|
+
kind: isInstance ? "page-occurrence" : "page-node",
|
|
156
|
+
ownerPackageId: owner,
|
|
157
|
+
pageId: screen.id,
|
|
158
|
+
sourceNodeId,
|
|
159
|
+
...(isInstance
|
|
160
|
+
? { instanceOf: { ...node.instance } }
|
|
161
|
+
: {}),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
return id;
|
|
165
|
+
};
|
|
166
|
+
const rootSceneId = walk(presentation.rootId, null);
|
|
167
|
+
if (rootSceneId) {
|
|
168
|
+
// The page node parents the projected root frame.
|
|
169
|
+
sink[rootSceneId].parent = pageId;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
page: {
|
|
173
|
+
bounds: {
|
|
174
|
+
height: presentation.viewport?.height ?? 0,
|
|
175
|
+
width: presentation.viewport?.width ?? 0,
|
|
176
|
+
x: 0,
|
|
177
|
+
y: 0,
|
|
178
|
+
},
|
|
179
|
+
children: rootSceneId ? [rootSceneId] : [],
|
|
180
|
+
decoration: false,
|
|
181
|
+
editableFields: [],
|
|
182
|
+
id: pageId,
|
|
183
|
+
kind: "page",
|
|
184
|
+
label: screen.name,
|
|
185
|
+
parent: null,
|
|
186
|
+
sourceRef: {
|
|
187
|
+
kind: "page-node",
|
|
188
|
+
ownerPackageId: owner,
|
|
189
|
+
pageId: screen.id,
|
|
190
|
+
sourceNodeId: screen.rootId,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
pageId,
|
|
194
|
+
rootSceneId,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build the generated canvas scene. Pure: reads snapshots, emits plain data.
|
|
199
|
+
export function buildCanvasScene(snapshot, options = {}) {
|
|
200
|
+
const combination = options.combination ?? [];
|
|
201
|
+
const revision = snapshot.revision;
|
|
202
|
+
const generation = digest(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
combination,
|
|
205
|
+
sceneVersion: CANVAS_SCENE_VERSION,
|
|
206
|
+
sources: componentSources(snapshot, options).map(
|
|
207
|
+
({ snapshot: source }) => [source.manifest.packageId, source.revision],
|
|
208
|
+
),
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
const nodes = {};
|
|
212
|
+
const rootIds = [];
|
|
213
|
+
|
|
214
|
+
// Observed set ids for the requested combination: drives the inactive
|
|
215
|
+
// marking on token specimens (DSC-005 semantics on the scene).
|
|
216
|
+
const observedSetIds = new Set(
|
|
217
|
+
(combination ?? []).flatMap((selection) => {
|
|
218
|
+
for (const entry of snapshot.manifest.entries.tokens) {
|
|
219
|
+
const library = snapshot.entries[entry];
|
|
220
|
+
const theme = (library.themes ?? []).find(
|
|
221
|
+
(candidate) => candidate.id === selection.themeId,
|
|
222
|
+
);
|
|
223
|
+
if (theme) return theme.setIds ?? [];
|
|
224
|
+
}
|
|
225
|
+
return [];
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Token board: one specimen scene node per inventory row.
|
|
230
|
+
const tokenBoardId = sceneNodeId("board", "tokens");
|
|
231
|
+
const tokenSections = new Map();
|
|
232
|
+
for (const { snapshot: source, owner } of componentSources(snapshot, options)) {
|
|
233
|
+
for (const row of tokenInventoryRows(source, options.foundation === source ? "foundation" : owner === snapshot.manifest.packageId ? "product" : "library")) {
|
|
234
|
+
const domain = row.setName.split("/")[0];
|
|
235
|
+
const sectionId = sceneNodeId("section", "tokens", domain);
|
|
236
|
+
if (!tokenSections.has(sectionId)) {
|
|
237
|
+
tokenSections.set(sectionId, {
|
|
238
|
+
bounds: null,
|
|
239
|
+
children: [],
|
|
240
|
+
decoration: true,
|
|
241
|
+
editableFields: [],
|
|
242
|
+
id: sectionId,
|
|
243
|
+
kind: "section",
|
|
244
|
+
label: domain,
|
|
245
|
+
parent: tokenBoardId,
|
|
246
|
+
// Section headers are generated decorations: no write target.
|
|
247
|
+
sourceRef: {
|
|
248
|
+
decorationId: sectionId,
|
|
249
|
+
kind: "decoration",
|
|
250
|
+
ownerPackageId: null,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const specimenId = sceneNodeId("tok", row.qualifiedKey);
|
|
255
|
+
tokenSections.get(sectionId).children.push(specimenId);
|
|
256
|
+
nodes[specimenId] = {
|
|
257
|
+
bounds: null,
|
|
258
|
+
children: [],
|
|
259
|
+
decoration: false,
|
|
260
|
+
editableFields: ["value"],
|
|
261
|
+
id: specimenId,
|
|
262
|
+
kind: "specimen",
|
|
263
|
+
label: row.path,
|
|
264
|
+
observed: observedSetIds.has(row.setId),
|
|
265
|
+
parent: sectionId,
|
|
266
|
+
sourceRef: {
|
|
267
|
+
kind: "token-cell",
|
|
268
|
+
ownerPackageId: owner,
|
|
269
|
+
path: row.path,
|
|
270
|
+
qualifiedKey: row.qualifiedKey,
|
|
271
|
+
setId: row.setId,
|
|
272
|
+
setName: row.setName,
|
|
273
|
+
tokenId: row.tokenId,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
nodes[tokenBoardId] = {
|
|
279
|
+
bounds: null,
|
|
280
|
+
children: [...tokenSections.keys()],
|
|
281
|
+
decoration: true,
|
|
282
|
+
editableFields: [],
|
|
283
|
+
id: tokenBoardId,
|
|
284
|
+
kind: "board",
|
|
285
|
+
label: "Tokens",
|
|
286
|
+
parent: null,
|
|
287
|
+
sourceRef: {
|
|
288
|
+
decorationId: tokenBoardId,
|
|
289
|
+
kind: "decoration",
|
|
290
|
+
ownerPackageId: null,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
for (const [id, section] of tokenSections) {
|
|
294
|
+
nodes[id] = section;
|
|
295
|
+
}
|
|
296
|
+
rootIds.push(tokenBoardId);
|
|
297
|
+
|
|
298
|
+
// Component families: one variant specimen per (set, variant).
|
|
299
|
+
const componentBoardId = sceneNodeId("board", "components");
|
|
300
|
+
const componentSections = new Map();
|
|
301
|
+
for (const { snapshot: source, owner } of componentSources(snapshot, options)) {
|
|
302
|
+
for (const entry of source.manifest.entries.components) {
|
|
303
|
+
const file = source.entries[entry];
|
|
304
|
+
for (const componentSet of file.componentSets ?? []) {
|
|
305
|
+
const sectionId = sceneNodeId("section", "cmp", owner, componentSet.id);
|
|
306
|
+
componentSections.set(sectionId, {
|
|
307
|
+
bounds: null,
|
|
308
|
+
children: [],
|
|
309
|
+
decoration: true,
|
|
310
|
+
editableFields: [],
|
|
311
|
+
id: sectionId,
|
|
312
|
+
kind: "section",
|
|
313
|
+
label: componentSet.name,
|
|
314
|
+
parent: componentBoardId,
|
|
315
|
+
sourceRef: {
|
|
316
|
+
componentId: componentSet.id,
|
|
317
|
+
decorationId: sectionId,
|
|
318
|
+
kind: "decoration",
|
|
319
|
+
ownerPackageId: owner,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
for (const variant of componentSet.variants) {
|
|
323
|
+
const variantId = componentVariantSceneNodes(
|
|
324
|
+
owner,
|
|
325
|
+
componentSet,
|
|
326
|
+
variant,
|
|
327
|
+
nodes,
|
|
328
|
+
);
|
|
329
|
+
componentSections.get(sectionId).children.push(variantId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
nodes[componentBoardId] = {
|
|
335
|
+
bounds: null,
|
|
336
|
+
children: [...componentSections.keys()],
|
|
337
|
+
decoration: true,
|
|
338
|
+
editableFields: [],
|
|
339
|
+
id: componentBoardId,
|
|
340
|
+
kind: "board",
|
|
341
|
+
label: "Components",
|
|
342
|
+
parent: null,
|
|
343
|
+
sourceRef: {
|
|
344
|
+
decorationId: componentBoardId,
|
|
345
|
+
kind: "decoration",
|
|
346
|
+
ownerPackageId: null,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
for (const [id, section] of componentSections) {
|
|
350
|
+
nodes[id] = section;
|
|
351
|
+
}
|
|
352
|
+
rootIds.push(componentBoardId);
|
|
353
|
+
|
|
354
|
+
// Page compositions: every ordinary page root and its descendants.
|
|
355
|
+
const pagesBoardId = sceneNodeId("board", "pages");
|
|
356
|
+
const pages = [];
|
|
357
|
+
for (const { snapshot: source, owner } of componentSources(snapshot, options)) {
|
|
358
|
+
for (const entry of source.manifest.entries.screens) {
|
|
359
|
+
const screen = source.entries[entry];
|
|
360
|
+
for (const presentation of screen.presentations) {
|
|
361
|
+
const { page } = pageSceneNodes(owner, screen, presentation, nodes);
|
|
362
|
+
nodes[page.id] = page;
|
|
363
|
+
// Pages sit in a section so every board keeps the same
|
|
364
|
+
// board -> section -> member shape the canvas renders.
|
|
365
|
+
const sectionId = sceneNodeId("section", "pages", page.id);
|
|
366
|
+
nodes[sectionId] = {
|
|
367
|
+
bounds: null,
|
|
368
|
+
children: [page.id],
|
|
369
|
+
decoration: true,
|
|
370
|
+
editableFields: [],
|
|
371
|
+
id: sectionId,
|
|
372
|
+
kind: "section",
|
|
373
|
+
label: page.label,
|
|
374
|
+
parent: pagesBoardId,
|
|
375
|
+
sourceRef: {
|
|
376
|
+
decorationId: sectionId,
|
|
377
|
+
kind: "decoration",
|
|
378
|
+
ownerPackageId: null,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
pages.push(sectionId);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
nodes[pagesBoardId] = {
|
|
386
|
+
bounds: null,
|
|
387
|
+
children: pages,
|
|
388
|
+
decoration: true,
|
|
389
|
+
editableFields: [],
|
|
390
|
+
id: pagesBoardId,
|
|
391
|
+
kind: "board",
|
|
392
|
+
label: "Pages",
|
|
393
|
+
parent: null,
|
|
394
|
+
sourceRef: {
|
|
395
|
+
decorationId: pagesBoardId,
|
|
396
|
+
kind: "decoration",
|
|
397
|
+
ownerPackageId: null,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
rootIds.push(pagesBoardId);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
combination,
|
|
404
|
+
generation,
|
|
405
|
+
nodes,
|
|
406
|
+
revision,
|
|
407
|
+
rootIds,
|
|
408
|
+
sceneVersion: CANVAS_SCENE_VERSION,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Serialization protocol: canonical JSON (sorted object keys) so the same
|
|
413
|
+
// scene always serializes to identical bytes and round-trips losslessly.
|
|
414
|
+
export function serializeCanvasScene(scene) {
|
|
415
|
+
const encode = (value) => {
|
|
416
|
+
if (Array.isArray(value)) {
|
|
417
|
+
return value.map(encode);
|
|
418
|
+
}
|
|
419
|
+
if (value && typeof value === "object") {
|
|
420
|
+
return Object.fromEntries(
|
|
421
|
+
Object.keys(value)
|
|
422
|
+
.sort()
|
|
423
|
+
.map((key) => [key, encode(value[key])]),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return value === undefined ? null : value;
|
|
427
|
+
};
|
|
428
|
+
return JSON.stringify({ ...encode(scene), serializationVersion: 1 });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function deserializeCanvasScene(json) {
|
|
432
|
+
const value = JSON.parse(json);
|
|
433
|
+
delete value.serializationVersion;
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// DSC-005/006/007: full-inventory catalog collectors feeding the canvas.
|
|
439
|
+
// Each collector reconciles one source family against the actual package
|
|
440
|
+
// entries and reports diagnostics instead of dropping anything silently.
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
// Token families the canvas renderer can draw. Any other type still enters
|
|
444
|
+
// the catalog but with a diagnostic, so nothing disappears silently.
|
|
445
|
+
export const CANVAS_RENDERABLE_TOKEN_TYPES = new Set([
|
|
446
|
+
"border-radius",
|
|
447
|
+
"color",
|
|
448
|
+
"shadow",
|
|
449
|
+
"sizing",
|
|
450
|
+
"spacing",
|
|
451
|
+
"stroke-width",
|
|
452
|
+
"typography",
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
const CANVAS_ALIAS_PATTERN = /^\{([^{}]+)\}$/;
|
|
456
|
+
|
|
457
|
+
export function collectCanvasTokenCatalog(snapshot, options = {}) {
|
|
458
|
+
const diagnostics = [];
|
|
459
|
+
const rows = [];
|
|
460
|
+
const observedSetIds = new Set(
|
|
461
|
+
(options.combination ?? []).flatMap((selection) => {
|
|
462
|
+
// Resolve themeId -> setIds from the product token library.
|
|
463
|
+
for (const entry of snapshot.manifest.entries.tokens) {
|
|
464
|
+
const library = snapshot.entries[entry];
|
|
465
|
+
const theme = (library.themes ?? []).find(
|
|
466
|
+
(candidate) => candidate.id === selection.themeId,
|
|
467
|
+
);
|
|
468
|
+
if (theme) return theme.setIds ?? [];
|
|
469
|
+
}
|
|
470
|
+
return [];
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
for (const { snapshot: source, kind } of componentSources(snapshot, options)) {
|
|
474
|
+
for (const row of tokenInventoryRows(source, kind)) {
|
|
475
|
+
const alias = CANVAS_ALIAS_PATTERN.exec(
|
|
476
|
+
typeof row.value === "string" ? row.value : "",
|
|
477
|
+
)?.[1];
|
|
478
|
+
const renderable = CANVAS_RENDERABLE_TOKEN_TYPES.has(
|
|
479
|
+
typeof row.type === "string" ? row.type : String(row.type),
|
|
480
|
+
);
|
|
481
|
+
if (!renderable) {
|
|
482
|
+
diagnostics.push({
|
|
483
|
+
code: "canvas_unsupported_token_type",
|
|
484
|
+
message: `Canvas cannot draw token type: ${row.type}`,
|
|
485
|
+
ownerPackageId: row.qualifiedKey?.split("/")[0] ?? null,
|
|
486
|
+
path: row.path,
|
|
487
|
+
type: row.type,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
rows.push({
|
|
491
|
+
active: row.active ?? false,
|
|
492
|
+
alias: alias ?? null,
|
|
493
|
+
observed: observedSetIds.has(row.setId),
|
|
494
|
+
ownerPackageId: row.qualifiedKey?.split("/")[0] ?? null,
|
|
495
|
+
path: row.path,
|
|
496
|
+
qualifiedKey: row.qualifiedKey,
|
|
497
|
+
raw: row.value,
|
|
498
|
+
setId: row.setId,
|
|
499
|
+
setName: row.setName,
|
|
500
|
+
status: row.status,
|
|
501
|
+
tokenId: row.tokenId,
|
|
502
|
+
type: row.type,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { diagnostics, rows };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function collectCanvasComponentCatalog(snapshot, options = {}) {
|
|
510
|
+
const families = [];
|
|
511
|
+
for (const { snapshot: source, owner } of componentSources(snapshot, options)) {
|
|
512
|
+
for (const entry of source.manifest.entries.components) {
|
|
513
|
+
const file = source.entries[entry];
|
|
514
|
+
for (const componentSet of file.componentSets ?? []) {
|
|
515
|
+
families.push({
|
|
516
|
+
// Usage locations are filled by collectCanvasUsageLocations.
|
|
517
|
+
id: componentSet.id,
|
|
518
|
+
name: componentSet.name,
|
|
519
|
+
ownerPackageId: owner,
|
|
520
|
+
usageLocations: [],
|
|
521
|
+
variants: componentSet.variants.map((variant) => ({
|
|
522
|
+
id: variant.id,
|
|
523
|
+
rootId: variant.rootId,
|
|
524
|
+
selection: { ...variant.selection },
|
|
525
|
+
sourceNodeIds: Object.keys(variant.nodes),
|
|
526
|
+
})),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return { families };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function collectCanvasUsageLocations(snapshot) {
|
|
535
|
+
const locations = [];
|
|
536
|
+
for (const entry of snapshot.manifest.entries.screens) {
|
|
537
|
+
const screen = snapshot.entries[entry];
|
|
538
|
+
for (const presentation of screen.presentations) {
|
|
539
|
+
for (const node of Object.values(presentation.nodes)) {
|
|
540
|
+
if (node.type !== "INSTANCE") continue;
|
|
541
|
+
locations.push({
|
|
542
|
+
componentId: node.instance?.component?.assetId,
|
|
543
|
+
instanceNodeId: node.id,
|
|
544
|
+
overrides: Object.keys(node.instance?.overrides ?? {}),
|
|
545
|
+
ownerPackageId: node.instance?.component?.packageId,
|
|
546
|
+
pageId: screen.id,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return locations;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function collectCanvasPageCatalog(snapshot) {
|
|
555
|
+
const pages = [];
|
|
556
|
+
for (const entry of snapshot.manifest.entries.screens) {
|
|
557
|
+
const screen = snapshot.entries[entry];
|
|
558
|
+
for (const presentation of screen.presentations) {
|
|
559
|
+
const nodes = Object.values(presentation.nodes);
|
|
560
|
+
const instances = nodes.filter((node) => node.type === "INSTANCE");
|
|
561
|
+
const hidden = nodes.filter(
|
|
562
|
+
(node) => node.visible === false,
|
|
563
|
+
);
|
|
564
|
+
const componentSetIds = new Set();
|
|
565
|
+
for (const componentEntry of snapshot.manifest.entries.components) {
|
|
566
|
+
const file = snapshot.entries[componentEntry];
|
|
567
|
+
for (const set of file.componentSets ?? []) {
|
|
568
|
+
componentSetIds.add(set.id);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Unregistered compositions: top-level frames below the page root
|
|
572
|
+
// that are not instances and whose names no component family owns.
|
|
573
|
+
const root = presentation.nodes[presentation.rootId];
|
|
574
|
+
const unregistered = (root?.children ?? [])
|
|
575
|
+
.map((childId) => presentation.nodes[childId])
|
|
576
|
+
.filter((node) => node && node.type !== "INSTANCE");
|
|
577
|
+
pages.push({
|
|
578
|
+
instances: instances.map((instance) => ({
|
|
579
|
+
componentId: instance.instance?.component?.assetId,
|
|
580
|
+
instanceNodeId: instance.id,
|
|
581
|
+
overrides: Object.keys(instance.instance?.overrides ?? {}),
|
|
582
|
+
})),
|
|
583
|
+
pageId: screen.id,
|
|
584
|
+
pageName: screen.name,
|
|
585
|
+
presentationId: presentation.id,
|
|
586
|
+
// Descendants stay reachable through sourceRef/tree; this count is
|
|
587
|
+
// the reconciliation number.
|
|
588
|
+
sourceNodeCount: nodes.length,
|
|
589
|
+
status: nodes.length === 0 ? "empty" : "ready",
|
|
590
|
+
unregisteredCompositions: unregistered.map((node) => ({
|
|
591
|
+
name: node.name,
|
|
592
|
+
sourceNodeId: node.id,
|
|
593
|
+
type: node.type,
|
|
594
|
+
})),
|
|
595
|
+
hiddenCount: hidden.length,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return { pages };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
// DSC-008: deterministic auto-layout. Pure: same inputs produce the same
|
|
604
|
+
// bounds. Text width comes from an injected measure callback when the host
|
|
605
|
+
// can measure real text; the default is a deterministic character heuristic.
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
export const CANVAS_LAYOUT_VERSION = 1;
|
|
609
|
+
|
|
610
|
+
const LAYOUT = {
|
|
611
|
+
boardGap: 160,
|
|
612
|
+
columnGap: 24,
|
|
613
|
+
headerHeight: 48,
|
|
614
|
+
pagePadding: 32,
|
|
615
|
+
rowGap: 24,
|
|
616
|
+
sectionGap: 96,
|
|
617
|
+
specimenHeight: 96,
|
|
618
|
+
specimenWidth: 200,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
function defaultMeasureText(text, fontSize) {
|
|
622
|
+
const size = typeof fontSize === "number" && fontSize > 0 ? fontSize : 14;
|
|
623
|
+
return Math.round(text.length * size * 0.62);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Assign world-coordinate bounds to every scene node and compute the total
|
|
627
|
+
// canvas bounds. Returns a new scene (input is not mutated) plus meta.
|
|
628
|
+
export function layoutCanvasScene(scene, options = {}) {
|
|
629
|
+
const measureText = options.measureText ?? defaultMeasureText;
|
|
630
|
+
const layout = {
|
|
631
|
+
bounds: null,
|
|
632
|
+
layoutVersion: CANVAS_LAYOUT_VERSION,
|
|
633
|
+
nodes: {},
|
|
634
|
+
};
|
|
635
|
+
const nodes = scene.nodes;
|
|
636
|
+
|
|
637
|
+
// Stable section order per board: sort by id (ids are identity-derived, so
|
|
638
|
+
// this is stable across rebuilds).
|
|
639
|
+
const boardChildren = (boardId) =>
|
|
640
|
+
[...(nodes[boardId]?.children ?? [])].sort();
|
|
641
|
+
|
|
642
|
+
const placeSection = (sectionId, x, y) => {
|
|
643
|
+
const section = nodes[sectionId];
|
|
644
|
+
const children = section.children;
|
|
645
|
+
const grid = options.specimenColumns ?? 4;
|
|
646
|
+
let maxWidth = 0;
|
|
647
|
+
let cursorX = x;
|
|
648
|
+
let cursorY = y + LAYOUT.headerHeight;
|
|
649
|
+
let rowHeight = 0;
|
|
650
|
+
let rowStartX = cursorX;
|
|
651
|
+
const columnWidths = [];
|
|
652
|
+
// Measure first: each column is as wide as its widest member so long
|
|
653
|
+
// labels never overlap neighbours.
|
|
654
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
655
|
+
const node = nodes[children[index]];
|
|
656
|
+
const textWidth = measureText(node.label, 14);
|
|
657
|
+
const width = Math.max(LAYOUT.specimenWidth, textWidth + 24);
|
|
658
|
+
columnWidths.push(width);
|
|
659
|
+
}
|
|
660
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
661
|
+
const childId = children[index];
|
|
662
|
+
const node = nodes[childId];
|
|
663
|
+
const width = columnWidths[index];
|
|
664
|
+
const height = node.kind === "node" && node.bounds
|
|
665
|
+
? Math.max(node.bounds.height, LAYOUT.specimenHeight)
|
|
666
|
+
: LAYOUT.specimenHeight;
|
|
667
|
+
layout.nodes[childId] = {
|
|
668
|
+
...node,
|
|
669
|
+
bounds: {
|
|
670
|
+
height,
|
|
671
|
+
width,
|
|
672
|
+
x: cursorX,
|
|
673
|
+
y: cursorY,
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
rowHeight = Math.max(rowHeight, height);
|
|
677
|
+
maxWidth = Math.max(maxWidth, cursorX + width - x);
|
|
678
|
+
// Rebase the specimen subtree (e.g. component variant children) from
|
|
679
|
+
// source-absolute to cell-relative coordinates.
|
|
680
|
+
const rebase = (sceneId, originX, originY, baseX, baseY) => {
|
|
681
|
+
const child = nodes[sceneId];
|
|
682
|
+
if (!child) return;
|
|
683
|
+
layout.nodes[sceneId] = {
|
|
684
|
+
...child,
|
|
685
|
+
bounds: {
|
|
686
|
+
height: child.bounds?.height ?? 0,
|
|
687
|
+
width: child.bounds?.width ?? 0,
|
|
688
|
+
x: baseX + ((child.bounds?.x ?? 0) - originX),
|
|
689
|
+
y: baseY + ((child.bounds?.y ?? 0) - originY),
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
for (const grandChildId of child.children ?? []) {
|
|
693
|
+
rebase(grandChildId, originX, originY, baseX, baseY);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
(node.children ?? []).forEach((childId) => {
|
|
697
|
+
const child = nodes[childId];
|
|
698
|
+
rebase(
|
|
699
|
+
childId,
|
|
700
|
+
child?.bounds?.x ?? 0,
|
|
701
|
+
child?.bounds?.y ?? 0,
|
|
702
|
+
cursorX,
|
|
703
|
+
cursorY,
|
|
704
|
+
);
|
|
705
|
+
});
|
|
706
|
+
const column = (index + 1) % grid;
|
|
707
|
+
if (column === 0) {
|
|
708
|
+
cursorX = rowStartX;
|
|
709
|
+
cursorY += rowHeight + LAYOUT.rowGap;
|
|
710
|
+
rowHeight = 0;
|
|
711
|
+
} else {
|
|
712
|
+
cursorX += width + LAYOUT.columnGap;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (children.length % grid !== 0) {
|
|
716
|
+
cursorY += rowHeight + LAYOUT.rowGap;
|
|
717
|
+
}
|
|
718
|
+
const sectionHeight = LAYOUT.headerHeight + (cursorY - y);
|
|
719
|
+
layout.nodes[sectionId] = {
|
|
720
|
+
...section,
|
|
721
|
+
bounds: {
|
|
722
|
+
height: sectionHeight,
|
|
723
|
+
width: Math.max(maxWidth, measureText(section.label, 18)),
|
|
724
|
+
x,
|
|
725
|
+
y,
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
return sectionHeight;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const placeBoard = (boardId, y) => {
|
|
732
|
+
const board = nodes[boardId];
|
|
733
|
+
let cursorY = y + LAYOUT.headerHeight;
|
|
734
|
+
let maxWidth = 0;
|
|
735
|
+
for (const sectionId of boardChildren(boardId)) {
|
|
736
|
+
const height = placeSection(sectionId, LAYOUT.pagePadding, cursorY);
|
|
737
|
+
const section = layout.nodes[sectionId];
|
|
738
|
+
maxWidth = Math.max(maxWidth, section.bounds.width);
|
|
739
|
+
cursorY += height + LAYOUT.sectionGap;
|
|
740
|
+
}
|
|
741
|
+
layout.nodes[boardId] = {
|
|
742
|
+
...board,
|
|
743
|
+
bounds: {
|
|
744
|
+
height: cursorY - y,
|
|
745
|
+
width: maxWidth + LAYOUT.pagePadding * 2,
|
|
746
|
+
x: 0,
|
|
747
|
+
y,
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
return cursorY - y + LAYOUT.sectionGap;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// Pages board: each page is a true-size stage (viewport geometry) placed
|
|
754
|
+
// in a fixed-column flow; descendants are rebased to page-relative source
|
|
755
|
+
// positions so the real design layout is preserved inside the stage.
|
|
756
|
+
const placePages = (boardId, y) => {
|
|
757
|
+
const board = nodes[boardId];
|
|
758
|
+
const columnGap = 120;
|
|
759
|
+
const columns = options.pageColumns ?? 2;
|
|
760
|
+
const pageNodes = board.children.map((sectionId) => {
|
|
761
|
+
const section = nodes[sectionId];
|
|
762
|
+
return section?.children?.[0] ?? sectionId;
|
|
763
|
+
});
|
|
764
|
+
const widths = pageNodes.map((pageId) => {
|
|
765
|
+
const root = nodes[pageId].children[0];
|
|
766
|
+
return nodes[root]?.bounds?.width ?? 800;
|
|
767
|
+
});
|
|
768
|
+
const heights = pageNodes.map((pageId) => {
|
|
769
|
+
const root = nodes[pageId].children[0];
|
|
770
|
+
return nodes[root]?.bounds?.height ?? 600;
|
|
771
|
+
});
|
|
772
|
+
const cellWidth = Math.max(...widths, 400);
|
|
773
|
+
const rowHeights = [];
|
|
774
|
+
let cursorX = 0;
|
|
775
|
+
let rowY = y + LAYOUT.headerHeight;
|
|
776
|
+
let column = 0;
|
|
777
|
+
let rowHeight = 0;
|
|
778
|
+
pageNodes.forEach((pageId, index) => {
|
|
779
|
+
if (column === columns) {
|
|
780
|
+
cursorX = 0;
|
|
781
|
+
rowY += rowHeight + LAYOUT.sectionGap;
|
|
782
|
+
column = 0;
|
|
783
|
+
rowHeight = 0;
|
|
784
|
+
}
|
|
785
|
+
const sectionId = board.children[index];
|
|
786
|
+
const page = nodes[pageId];
|
|
787
|
+
const rootSceneId = page.children[0];
|
|
788
|
+
const rootSource = nodes[rootSceneId]?.bounds ?? { x: 0, y: 0 };
|
|
789
|
+
const width = widths[index];
|
|
790
|
+
const height = heights[index];
|
|
791
|
+
layout.nodes[pageId] = {
|
|
792
|
+
...page,
|
|
793
|
+
bounds: { height, width, x: cursorX, y: rowY },
|
|
794
|
+
};
|
|
795
|
+
layout.nodes[sectionId] = {
|
|
796
|
+
...nodes[sectionId],
|
|
797
|
+
bounds: { height: height + 24, width, x: cursorX, y: rowY - 24 },
|
|
798
|
+
};
|
|
799
|
+
layout.nodes[rootSceneId] = {
|
|
800
|
+
...nodes[rootSceneId],
|
|
801
|
+
bounds: { height, width, x: cursorX, y: rowY },
|
|
802
|
+
};
|
|
803
|
+
// Rebase every descendant from source-absolute to stage-relative.
|
|
804
|
+
const rebase = (sceneId, originX, originY) => {
|
|
805
|
+
const node = nodes[sceneId];
|
|
806
|
+
if (!node) return;
|
|
807
|
+
layout.nodes[sceneId] = {
|
|
808
|
+
...node,
|
|
809
|
+
bounds: {
|
|
810
|
+
height: node.bounds?.height ?? 0,
|
|
811
|
+
width: node.bounds?.width ?? 0,
|
|
812
|
+
x: cursorX + ((node.bounds?.x ?? 0) - originX),
|
|
813
|
+
y: rowY + ((node.bounds?.y ?? 0) - originY),
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
for (const childId of node.children ?? []) {
|
|
817
|
+
rebase(childId, originX, originY);
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
rebase(rootSceneId, rootSource.x, rootSource.y);
|
|
821
|
+
rowHeight = Math.max(rowHeight, height);
|
|
822
|
+
cursorX += cellWidth + columnGap;
|
|
823
|
+
column += 1;
|
|
824
|
+
});
|
|
825
|
+
const boardHeight = LAYOUT.headerHeight + rowY + rowHeight - y;
|
|
826
|
+
const boardWidth = columns * (cellWidth + columnGap);
|
|
827
|
+
layout.nodes[boardId] = {
|
|
828
|
+
...board,
|
|
829
|
+
bounds: { height: boardHeight, width: boardWidth, x: 0, y },
|
|
830
|
+
};
|
|
831
|
+
return boardHeight;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
let cursorY = 0;
|
|
835
|
+
const totalWidths = [];
|
|
836
|
+
for (const rootId of scene.rootIds) {
|
|
837
|
+
const height =
|
|
838
|
+
rootId === sceneNodeIdBoard("pages")
|
|
839
|
+
? placePages(rootId, cursorY)
|
|
840
|
+
: placeBoard(rootId, cursorY);
|
|
841
|
+
const board = layout.nodes[rootId];
|
|
842
|
+
totalWidths.push(board.bounds.width);
|
|
843
|
+
cursorY += height;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Page compositions keep their true viewport geometry, positioned by the
|
|
847
|
+
// pages board flow (already handled by placeSection via source bounds for
|
|
848
|
+
// page nodes); page frames carry real width/height from the presentation.
|
|
849
|
+
const canvasBounds = {
|
|
850
|
+
height: cursorY,
|
|
851
|
+
width: Math.max(...totalWidths, 800),
|
|
852
|
+
x: 0,
|
|
853
|
+
y: 0,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
bounds: canvasBounds,
|
|
858
|
+
layoutVersion: CANVAS_LAYOUT_VERSION,
|
|
859
|
+
measure: options.measureText ? "injected" : "heuristic",
|
|
860
|
+
nodes: layout.nodes,
|
|
861
|
+
};
|
|
862
|
+
}
|