@smallpen/cli 0.1.0-alpha.1 → 0.1.0-alpha.12

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.
@@ -0,0 +1,939 @@
1
+ import {
2
+ appliedTokenFields,
3
+ asciiWireframe,
4
+ createSemanticTree,
5
+ defaultTokenThemeIds,
6
+ listTokenThemes,
7
+ layoutProjectionDiagnostics,
8
+ projectComponentVariant,
9
+ projectDesignView,
10
+ resolveContext,
11
+ resolveDesignView,
12
+ resolveEffectiveToken,
13
+ SmallPenError,
14
+ tokenLibraryOf,
15
+ } from "@smallpen/core";
16
+ import { inspectProjection } from "@smallpen/local-package";
17
+
18
+ export async function resolveDesignLayout(workspace, design, options = {}) {
19
+ if (design.layoutSnapshot && (!options.raster || design.render?.bytes?.length)) return design;
20
+ const result = await inspectProjection(workspace.product, design.renderProjection ?? design.projection, {
21
+ ...workspace, ...options, selection: design.selection, nodeId: design.tree.root.id,
22
+ });
23
+ const tree = structuredClone(design.tree);
24
+ const update = node => {
25
+ node.bounds = result.snapshot.nodes[node.id]?.bounds ?? null;
26
+ node.visible = result.snapshot.nodes[node.id]?.visible ?? node.visible;
27
+ node.children.forEach(update);
28
+ };
29
+ update(tree.root);
30
+ const nodes = Object.fromEntries(Object.entries(design.projection.nodes).map(([id, node]) => {
31
+ const bounds = result.snapshot.nodes[id]?.layoutBox;
32
+ return [id, bounds ? { ...node, width: bounds.width, height: bounds.height } : node];
33
+ }));
34
+ const relevant = new Set([...Object.keys(design.projection.nodes), ...(design.ancestors ?? []).map(node => node.id)]);
35
+ const imported = [...new Set([workspace.product, workspace.foundation, ...(workspace.libraries ?? [])]
36
+ .filter(Boolean).flatMap(owner => owner.manifest.entries.assets.flatMap(entry =>
37
+ (owner.entries[entry].fonts ?? []).map(font => font.family))))].sort();
38
+ const snapshot = { ...result.snapshot, diagnostics: result.snapshot.diagnostics.filter(issue =>
39
+ !issue.nodeId || relevant.has(issue.nodeId)).map(issue => {
40
+ if (issue.code !== "font_render_fallback") return issue;
41
+ const requestedFont = issue.details.requestedFont;
42
+ const availableFonts = ["Source Sans Pro (bundled)", ...imported.map(name => `${name} (imported)`)];
43
+ return { ...issue, requestedFont, availableFonts,
44
+ message: `${issue.message}. Available fonts: ${availableFonts.join(", ")}. Import with smallpen asset font import, or use an available family.`,
45
+ nextOperations: [{ operation: "smallpen.import-font", argv: ["import-font", workspace.product.locator,
46
+ "--file", `<${requestedFont} .ttf|.otf|.woff file>`, "--family", requestedFont, "--weight", "<400|700|...>", "--json"] }],
47
+ };
48
+ }) };
49
+ return {
50
+ ...design, tree, ancestors: design.ancestors ?? [], authoredProjection: design.authoredProjection ?? design.projection,
51
+ projection: { ...design.projection, nodes,
52
+ diagnostics: snapshot.diagnostics },
53
+ layoutSnapshot: snapshot, render: result.render,
54
+ };
55
+ }
56
+
57
+ export function themeSettings(workspace, { full = false } = {}) {
58
+ const { product, foundation } = workspace;
59
+ const themes = listTokenThemes(product, foundation);
60
+ const defaults = new Map(
61
+ [
62
+ product,
63
+ ...(foundation && foundation !== product ? [foundation] : []),
64
+ ].map((snapshot) => [
65
+ snapshot.manifest.packageId,
66
+ defaultTokenThemeIds(tokenLibraryOf(snapshot)),
67
+ ]),
68
+ );
69
+ const groups = [...new Set(themes.map(({ group }) => group))].map((name) => {
70
+ const options = themes
71
+ .filter(({ group }) => group === name)
72
+ .map(({ setIds, sets, ...theme }) => ({
73
+ ...theme,
74
+ // Which token Sets an option activates is storage; --full shows it.
75
+ ...(full ? { setIds, sets } : {}),
76
+ default: defaults.get(theme.packageId)?.includes(theme.id) ?? false,
77
+ }));
78
+ // Agents name options as Group/Option; IDs and Sets need --full.
79
+ if (!full)
80
+ return {
81
+ name,
82
+ default: options.find((option) => option.default)?.path,
83
+ selected: options.find(({ active }) => active)?.path,
84
+ options: options.map(({ path }) => path),
85
+ };
86
+ return {
87
+ name,
88
+ default: options.find((option) => option.default),
89
+ selected: options.find(({ active }) => active)?.path,
90
+ options,
91
+ };
92
+ });
93
+ return {
94
+ groups,
95
+ selection: {
96
+ themes: themes.filter(({ active }) => active).map(({ path }) => path),
97
+ },
98
+ ...(full && workspace.appSelection
99
+ ? { appSelection: workspace.appSelection }
100
+ : {}),
101
+ };
102
+ }
103
+
104
+ const CHANGE_LABELS = {
105
+ cornerRadius: "radius",
106
+ fills: "fill",
107
+ height: "size",
108
+ instance: "nested variant",
109
+ strokes: "stroke",
110
+ textBlocks: "text",
111
+ tokenBindings: "token binding",
112
+ visible: "visibility",
113
+ width: "size",
114
+ x: "position",
115
+ y: "position",
116
+ };
117
+
118
+ function variantPositions(variant) {
119
+ const nodes = new Map();
120
+ const visit = (id, position) => {
121
+ const node = variant.nodes[id];
122
+ if (!node) return;
123
+ nodes.set(position, node);
124
+ for (const [index, childId] of (node.children ?? []).entries())
125
+ visit(childId, `${position}/${index}`);
126
+ };
127
+ visit(variant.rootId, "root");
128
+ return nodes;
129
+ }
130
+
131
+ // What one variant changes against another: node name -> change labels.
132
+ function variantChanges(left, right, changes = new Map()) {
133
+ const add = (name, label) => {
134
+ if (!changes.has(name)) changes.set(name, new Set());
135
+ changes.get(name).add(label);
136
+ };
137
+ const a = variantPositions(left);
138
+ const b = variantPositions(right);
139
+ for (const position of new Set([...a.keys(), ...b.keys()])) {
140
+ const before = a.get(position);
141
+ const after = b.get(position);
142
+ const name = (after ?? before).name;
143
+ if (!before || !after) {
144
+ add(name, before ? "removed" : "added");
145
+ continue;
146
+ }
147
+ for (const field of new Set([...Object.keys(before), ...Object.keys(after)])) {
148
+ if (["id", "name", "children", "sourceNodeId", "touched"].includes(field)) continue;
149
+ if (JSON.stringify(before[field]) !== JSON.stringify(after[field]))
150
+ add(name, CHANGE_LABELS[field] ?? field);
151
+ }
152
+ }
153
+ return changes;
154
+ }
155
+
156
+ // "Node: a, b" lines; a rebinding that also changes the value says nothing more.
157
+ function changeLines(changes) {
158
+ return [...changes].map(([name, labels]) => {
159
+ const shown = [...labels].filter((label) =>
160
+ label !== "token binding" || labels.size === 1);
161
+ return `${name}: ${shown.join(", ")}`;
162
+ });
163
+ }
164
+
165
+ export function axisLabel(axis, value) {
166
+ return `${axis.name}=${axis.labels?.[value] ?? value}`;
167
+ }
168
+
169
+ // names: {token(reference) -> path, component(reference) -> name}, so the
170
+ // summary reads by name once IDs are left out of the reply.
171
+ export function componentSummary(component, names = {}) {
172
+ const variants = component.variants;
173
+ const first = variants[0];
174
+ const tokens = new Map(),
175
+ nested = new Map(),
176
+ differences = new Set();
177
+ const baseline = variantPositions(first);
178
+ for (const variant of variants)
179
+ for (const [position, node] of variantPositions(variant)) {
180
+ const base = baseline.get(position);
181
+ for (const field of new Set([...Object.keys(node), ...Object.keys(base ?? {})]))
182
+ if (!["id", "name", "children"].includes(field) &&
183
+ JSON.stringify(node[field]) !== JSON.stringify(base?.[field]))
184
+ differences.add(field);
185
+ if ((node.children ?? []).length !== (base?.children ?? []).length) differences.add("children");
186
+ for (const reference of Object.values(node.tokenBindings ?? {}))
187
+ tokens.set(JSON.stringify(reference), reference);
188
+ if (node.instance)
189
+ nested.set(JSON.stringify(node.instance.component), node.instance.component);
190
+ }
191
+ // Each property: what changes between variants that differ only in it.
192
+ const axes = (component.axes ?? []).map((axis) => {
193
+ const changes = new Map();
194
+ let pairs = 0;
195
+ for (const left of variants) {
196
+ for (const right of variants) {
197
+ if (pairs >= 24) break;
198
+ if (left === right) continue;
199
+ const only = (component.axes ?? []).every((other) =>
200
+ other.id === axis.id
201
+ ? left.selection?.[other.id] !== right.selection?.[other.id]
202
+ : left.selection?.[other.id] === right.selection?.[other.id]);
203
+ if (!only || left.selection?.[axis.id] !== first.selection?.[axis.id]) continue;
204
+ pairs += 1;
205
+ variantChanges(left, right, changes);
206
+ }
207
+ }
208
+ const used = [...new Set(variants.map((variant) => variant.selection?.[axis.id]))]
209
+ .filter((value) => value !== undefined);
210
+ const values = (axis.domain ?? used).filter((value) => used.includes(value));
211
+ const lines = changeLines(changes);
212
+ return {
213
+ id: axis.id,
214
+ name: axis.name,
215
+ values: values.map((value) => axis.labels?.[value] ?? value),
216
+ changes: lines.slice(0, 6),
217
+ ...(lines.length > 6 ? { moreChanges: lines.length - 6 } : {}),
218
+ };
219
+ });
220
+ return {
221
+ id: component.id,
222
+ name: component.name,
223
+ variantCount: variants.length,
224
+ mainVariant: {
225
+ id: first.id,
226
+ selection: (component.axes ?? []).map((axis) => axisLabel(axis, first.selection?.[axis.id])),
227
+ },
228
+ axes,
229
+ // Raw fields any variant changes against the main one.
230
+ differences: [...differences].sort(),
231
+ tokenBindings: [...tokens.values()].map((reference) => ({ ...reference, ...(names.token?.(reference) ? { path: names.token(reference) } : {}) })),
232
+ nestedComponents: [...nested.values()].map((reference) => ({ ...reference, ...(names.component?.(reference) ? { name: names.component(reference) } : {}) })),
233
+ };
234
+ }
235
+
236
+ // Readable header lines for a component summary.
237
+ // heading names what the structure below shows; by default the main variant.
238
+ export function componentSummaryText(summary, components, heading = "Main variant structure:") {
239
+ const lines = [
240
+ `${summary.name} · component · ${summary.variantCount} variant${summary.variantCount === 1 ? "" : "s"}${summary.mainVariant.selection.length ? ` · main: ${summary.mainVariant.selection.join(", ")}` : ""}`,
241
+ ];
242
+ for (const axis of summary.axes) {
243
+ lines.push(` ${axis.name}: ${axis.values.join(" / ")}${axis.changes.length ? ` → changes ${axis.changes.join("; ")}${axis.moreChanges ? `; +${axis.moreChanges} more` : ""}` : " → no visible change"}`);
244
+ }
245
+ if (summary.nestedComponents.length) {
246
+ lines.push(` nested: ${summary.nestedComponents.map((reference) => components?.(reference)?.name ?? reference.assetId).join(", ")}`);
247
+ }
248
+ lines.push(heading);
249
+ return lines.join("\n");
250
+ }
251
+
252
+ // Component Sets by reference, across the Product, Foundation and libraries.
253
+ export function componentLookup(workspace) {
254
+ const sets = new Map();
255
+ for (const snapshot of [workspace.product, workspace.foundation, ...(workspace.libraries ?? [])]) {
256
+ if (!snapshot) continue;
257
+ for (const set of snapshot.domain.componentSets.values()) {
258
+ sets.set(`${snapshot.manifest.packageId}\0${set.id}`, set);
259
+ if (!sets.has(set.id)) sets.set(set.id, set);
260
+ }
261
+ }
262
+ return (reference) =>
263
+ reference
264
+ ? (sets.get(`${reference.packageId}\0${reference.assetId}`) ?? sets.get(reference.assetId))
265
+ : undefined;
266
+ }
267
+
268
+ export function selectedDesign(workspace, selector, target = {}) {
269
+ const { product, foundation, libraries = [] } = workspace;
270
+ const options = { foundation, libraries };
271
+ let projection, selection, component;
272
+ if (target.componentId) {
273
+ const owners = [...new Set([product, foundation, ...libraries])].filter(
274
+ (snapshot) =>
275
+ snapshot?.domain.componentSets.has(target.componentId) &&
276
+ (!target.packageId || target.packageId === snapshot.manifest.packageId),
277
+ );
278
+ if (owners.length > 1)
279
+ throw new SmallPenError(
280
+ "ambiguous_component",
281
+ "Component ID exists in several owners; pass --package-id",
282
+ { packageIds: owners.map((snapshot) => snapshot.manifest.packageId) },
283
+ );
284
+ const owner = owners[0];
285
+ const set = owner?.domain.componentSets.get(target.componentId);
286
+ if (!set)
287
+ throw new SmallPenError(
288
+ "missing_component",
289
+ `No Component Set ${target.componentId}; use search-components to find its owning package`,
290
+ );
291
+ const variant = target.variantId
292
+ ? set.variants.find(({ id }) => id === target.variantId)
293
+ : set.variants[0];
294
+ if (!variant)
295
+ throw new SmallPenError(
296
+ "missing_variant",
297
+ `No variant ${target.variantId}`,
298
+ { validVariantIds: set.variants.map(({ id }) => id) },
299
+ );
300
+ const context = resolveContext(product, foundation, selector.context ?? {});
301
+ const nodes = projectComponentVariant(product, variant, {
302
+ ...options,
303
+ owner,
304
+ context,
305
+ });
306
+ projection = {
307
+ nodes,
308
+ rootId: variant.rootId,
309
+ context,
310
+ diagnostics: layoutProjectionDiagnostics(nodes),
311
+ };
312
+ selection = {
313
+ componentId: set.id,
314
+ variantId: variant.id,
315
+ packageId: owner.manifest.packageId,
316
+ context,
317
+ };
318
+ const known = [product, options.foundation, ...(options.libraries ?? [])].filter(Boolean);
319
+ const ownerOf = (reference) => known.find((snapshot) => snapshot.manifest.packageId === reference?.packageId);
320
+ component = componentSummary(set, {
321
+ token: (reference) => ownerOf(reference)?.domain.tokens.get(reference.assetId)?.path,
322
+ component: (reference) => ownerOf(reference)?.domain.componentSets.get(reference.assetId)?.name,
323
+ });
324
+ } else {
325
+ const resolved = resolveDesignView(product, {
326
+ ...options,
327
+ selector: { ...selector, viewFormat: "semantic" },
328
+ });
329
+ projection = projectDesignView(product, resolved, options);
330
+ const { viewFormat: _format, ...resolvedSelection } = resolved.selection;
331
+ selection = resolvedSelection;
332
+ }
333
+ const wholeTree = createSemanticTree(product, projection);
334
+ const renderProjection = projection;
335
+ let ancestors = [];
336
+ const find = (node, parents = []) => {
337
+ if (node.id === target.nodeId) {
338
+ ancestors = parents;
339
+ return node;
340
+ }
341
+ for (const child of node.children ?? []) {
342
+ const found = find(child, [...parents, projection.nodes[node.id]]);
343
+ if (found) return found;
344
+ }
345
+ };
346
+ let root = target.nodeId ? find(wholeTree.root) : wholeTree.root;
347
+ if (!root)
348
+ throw new SmallPenError(
349
+ "missing_node",
350
+ `Projected Node not found: ${target.nodeId}`,
351
+ );
352
+ if (ancestors.some((node) => node.visible === false))
353
+ root = { ...root, visible: false };
354
+ if (target.nodeId) {
355
+ const nodes = {};
356
+ const visit = (node) => {
357
+ nodes[node.id] = projection.nodes[node.id];
358
+ for (const child of node.children) visit(child);
359
+ };
360
+ visit(root);
361
+ const relevant = new Set([
362
+ ...Object.keys(nodes),
363
+ ...ancestors.map((node) => node.id),
364
+ ]);
365
+ projection = {
366
+ ...projection,
367
+ nodes,
368
+ rootId: root.id,
369
+ diagnostics: (projection.diagnostics ?? []).filter(
370
+ (diagnostic) => !diagnostic.nodeId || relevant.has(diagnostic.nodeId),
371
+ ),
372
+ };
373
+ selection = { ...selection, nodeId: target.nodeId };
374
+ }
375
+ const references = new Map();
376
+ for (const node of Object.values(projection.nodes))
377
+ for (const reference of Object.values(node.tokenBindings ?? {}))
378
+ references.set(JSON.stringify(reference), reference);
379
+ const tokenSources = [...references.values()].map((reference) => {
380
+ const resolved = resolveEffectiveToken(product, reference, {
381
+ ...options,
382
+ context: selection.context,
383
+ });
384
+ return {
385
+ reference,
386
+ path: resolved.token.path,
387
+ value: resolved.value,
388
+ sourcePackageId: resolved.sourcePackageId,
389
+ sourceTokenId: resolved.sourceTokenId,
390
+ };
391
+ });
392
+ const origin = renderProjection.nodes[renderProjection.rootId];
393
+ const crop = target.nodeId
394
+ ? {
395
+ x: root.bounds.x - origin.x,
396
+ y: root.bounds.y - origin.y,
397
+ width: root.bounds.width,
398
+ height: root.bounds.height,
399
+ }
400
+ : undefined;
401
+ return {
402
+ projection,
403
+ renderProjection,
404
+ crop,
405
+ ancestors,
406
+ tree: { ...wholeTree, root },
407
+ tokenSources,
408
+ selection: { ...selection, ...themeSettings(workspace).selection },
409
+ ...(component ? { component } : {}),
410
+ };
411
+ }
412
+
413
+ // Texts in reading order: a flex line places its last stored child first.
414
+ function descendantTexts(node, projection) {
415
+ if (!node.visible) return [];
416
+ const raw = projection?.nodes?.[node.id];
417
+ const direction = raw?.layout === "flex" ? raw["layout-flex-dir"] ?? "row" : null;
418
+ const stored = node.children ?? [];
419
+ const children = direction && !direction.endsWith("reverse") ? [...stored].reverse() : stored;
420
+ return [node.text, ...children.flatMap((child) => descendantTexts(child, projection))].filter(
421
+ Boolean,
422
+ );
423
+ }
424
+ function instanceReference(node, projection) {
425
+ const raw = projection.nodes[node.id];
426
+ return raw.type === "INSTANCE" || raw.instance
427
+ ? (node.component ?? raw.instance?.component)
428
+ : undefined;
429
+ }
430
+
431
+ // Quoted; a long text says how much the outline left out, so a cut here is
432
+ // not read as text the design clips.
433
+ function shortText(texts, limit = 60) {
434
+ const text = texts.join(" / ");
435
+ return text.length > limit ? `"${text.slice(0, limit - 1)}…" (+${text.length - limit + 1} chars)` : `"${text}"`;
436
+ }
437
+
438
+ // Visual values written as literals: the places a Token could be bound.
439
+ function literalFields(raw) {
440
+ // Applied Token names from older App edits bind as well.
441
+ const applied = appliedTokenFields(raw.appliedTokens);
442
+ const bound = (...fields) => fields.some((field) => raw.tokenBindings?.[field] || applied[field]);
443
+ const literal = [];
444
+ if (raw.fills?.length && !bound("fill", "fills.0")) literal.push("fill");
445
+ if (raw.strokes?.length && !bound("stroke", "strokes.0")) literal.push("stroke");
446
+ if (raw.cornerRadius && !bound("cornerRadius", "radiusTopLeft", "radiusTopRight", "radiusBottomRight", "radiusBottomLeft")) literal.push("radius");
447
+ if (raw.shadow && (!Array.isArray(raw.shadow) || raw.shadow.length) && !bound("shadow")) literal.push("shadow");
448
+ if (raw.type === "TEXT" && raw.textStyle && !bound("typography", "fontSize", "fontFamily", "fontWeight", "lineHeight", "letterSpacing")) literal.push("type");
449
+ return literal;
450
+ }
451
+
452
+ // An instance's variant as "Property=value" pairs, in property order.
453
+ function variantPairs(selection, set) {
454
+ if (!selection) return [];
455
+ const axes = set?.axes ?? [];
456
+ const known = axes.filter((axis) => selection[axis.id] !== undefined)
457
+ .map((axis) => [axis.id, axisLabel(axis, selection[axis.id])]);
458
+ const rest = Object.entries(selection)
459
+ .filter(([id]) => !axes.some((axis) => axis.id === id))
460
+ .map(([id, value]) => [id, `${id}=${value}`]);
461
+ return [...known, ...rest];
462
+ }
463
+
464
+ // One line per meaningful element: what it is, its size and layout, its
465
+ // text, and any visual values left as literals. Component internals are
466
+ // folded; adjacent copies of one component share a line that lists only
467
+ // what differs between them.
468
+ // Node IDs show only with full: agents read and edit by name.
469
+ export function outlineTree(tree, projection, { full = false, components, ids = full } = {}) {
470
+ const lines = [];
471
+ const size = (node) => `${Math.round(node.bounds.width)}×${Math.round(node.bounds.height)}`;
472
+ const describe = (node) => {
473
+ const raw = projection.nodes[node.id];
474
+ const reference = instanceReference(node, projection);
475
+ if (reference) {
476
+ const set = components?.(reference);
477
+ return { reference, set, pairs: variantPairs(node.variant ?? raw.instance?.variant, set) };
478
+ }
479
+ return { reference: null };
480
+ };
481
+ const label = (node) => {
482
+ const raw = projection.nodes[node.id];
483
+ const { reference, set, pairs } = describe(node);
484
+ if (reference) {
485
+ const texts = descendantTexts(node, projection);
486
+ // The element's own name first when it is not the component's, so
487
+ // --element finds it: "Save button: Button (Style=primary)".
488
+ const own = node.name && set?.name && node.name.trim().toLowerCase() !== set.name.trim().toLowerCase() ? `${node.name}: ` : "";
489
+ return `${own}${set?.name ?? reference.assetId}${pairs.length ? ` (${pairs.map(([, text]) => text).join(", ")})` : ""}${texts.length ? ` ${shortText(texts)}` : ""} · ${size(node)}${ids ? ` #${node.id}` : ""}`;
490
+ }
491
+ const children = node.children.filter(({ visible }) => visible).length;
492
+ const layout = raw.layout === "flex"
493
+ ? ` · ${raw["layout-flex-dir"]?.startsWith("column") ? "column" : "row"} layout (${children})`
494
+ : raw.layout
495
+ ? ` · ${raw.layout} layout (${children})`
496
+ : children && node.type !== "TEXT"
497
+ ? ` · ${children} item${children === 1 ? "" : "s"}`
498
+ : "";
499
+ const literal = literalFields(raw);
500
+ return `${node.name} · ${node.type} ${size(node)}${layout}${node.text ? ` ${shortText([node.text])}` : ""}${literal.length ? ` · literal ${literal.join(",")}` : ""}${ids ? ` #${node.id}` : ""}`;
501
+ };
502
+ const visit = (node, depth) => {
503
+ if (!node.visible) return;
504
+ const raw = projection.nodes[node.id];
505
+ if (!full && raw.designSystem?.role === "decoration") return;
506
+ lines.push(`${" ".repeat(depth)}${label(node)}`);
507
+ if (!full && node !== tree.root && instanceReference(node, projection))
508
+ return;
509
+ // A flex line places its last stored child first: list children in the
510
+ // order they appear.
511
+ const direction = raw.layout === "flex" ? raw["layout-flex-dir"] ?? "row" : null;
512
+ const stored = node.children.filter(({ visible }) => visible);
513
+ const children = direction && !direction.endsWith("reverse") ? [...stored].reverse() : stored;
514
+ for (let i = 0; i < children.length;) {
515
+ const child = children[i];
516
+ const ref = instanceReference(child, projection);
517
+ let end = i + 1;
518
+ if (!full && ref)
519
+ while (
520
+ end < children.length &&
521
+ JSON.stringify(instanceReference(children[end], projection)) ===
522
+ JSON.stringify(ref)
523
+ )
524
+ end++;
525
+ if (end - i > 1) {
526
+ const group = children.slice(i, end).map((item) => ({ item, ...describe(item) }));
527
+ // Variant pairs every copy shares are written once.
528
+ const shared = group[0].pairs.filter(([id, text]) =>
529
+ group.every(({ pairs }) => pairs.some(([other, value]) => other === id && value === text)));
530
+ const sizes = new Set(group.map(({ item }) => size(item)));
531
+ const items = group.map(({ item, pairs }) => {
532
+ const own = pairs.filter(([id]) => !shared.some(([other]) => other === id)).map(([, text]) => text);
533
+ const texts = descendantTexts(item, projection);
534
+ // Without IDs a copy is named, so --element can find it.
535
+ const shown = ids
536
+ ? (texts.length ? shortText(texts, 48) : item.name)
537
+ : `${item.name}${texts.length ? ` ${shortText(texts, 48)}` : ""}`;
538
+ return `${shown}${own.length ? ` (${own.join(", ")})` : ""}${sizes.size > 1 ? ` ${size(item)}` : ""}${ids ? ` #${item.id}` : ""}`;
539
+ });
540
+ lines.push(
541
+ `${" ".repeat(depth + 1)}${group[0].set?.name ?? ref.assetId} ×${end - i}${shared.length ? ` (${shared.map(([, text]) => text).join(", ")})` : ""}${sizes.size === 1 ? ` · ${[...sizes][0]}` : ""}: ${items.join("; ")}`,
542
+ );
543
+ } else visit(child, depth + 1);
544
+ i = end;
545
+ }
546
+ };
547
+ visit(tree.root, 0);
548
+ return lines.join("\n");
549
+ }
550
+
551
+ export function localWireframe(design, { full = false } = {}) {
552
+ const collapse = (node) => ({
553
+ ...node,
554
+ children:
555
+ !full &&
556
+ node !== design.tree.root &&
557
+ instanceReference(node, design.projection)
558
+ ? []
559
+ : node.children.filter(({ visible }) => design.layoutSnapshot || visible).map(collapse),
560
+ });
561
+ if (design.layoutSnapshot) {
562
+ const snapshot = design.layoutSnapshot;
563
+ const header = `Geometry: ${snapshot.status} · renderer: ${snapshot.renderer} · revision: ${snapshot.revision}\nSnapshot: ${snapshot.id}\nWireframe: spatial orientation only; use spec for constraints and PNG for appearance.\n`;
564
+ const unknown = Object.entries(snapshot.nodes).filter(([, value]) => !value.bounds);
565
+ if (unknown.length)
566
+ return `${header}No spatial drawing: native geometry is incomplete.\n${unknown.map(([id, value]) => `${design.projection.nodes[id]?.name ?? "Element"}: x=? y=? width=? height=? · partial (${value.reason})`).join("\n")}`;
567
+ return header + asciiWireframe({ ...design.tree, root: collapse(design.tree.root) })
568
+ .replace("view --as text lists exact sizes", "view --as spec lists native bounds and authored constraints");
569
+ }
570
+ return asciiWireframe({ ...design.tree, root: collapse(design.tree.root) });
571
+ }
572
+
573
+ function solidColor(node) {
574
+ if ((node.opacity ?? 1) !== 1 || node.fills?.length !== 1) return undefined;
575
+ const paint = node.fills[0];
576
+ if (
577
+ paint.type !== "solid" ||
578
+ (paint.opacity ?? 1) !== 1 ||
579
+ !/^#[0-9a-f]{6}$/i.test(paint.color ?? "")
580
+ )
581
+ return undefined;
582
+ return [1, 3, 5].map(
583
+ (offset) => parseInt(paint.color.slice(offset, offset + 2), 16) / 255,
584
+ );
585
+ }
586
+ const luminance = (rgb) =>
587
+ rgb
588
+ .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
589
+ .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
590
+
591
+ const PLATFORM_AXIS = /^(platform|viewport|device)$/i;
592
+
593
+ // Reminders, not issues: a Presentation for one platform checked without
594
+ // that platform's theme option, or using another platform's variant. The
595
+ // caller decides; nothing is stored.
596
+ export function platformHints(workspace, design) {
597
+ const { product, foundation } = workspace;
598
+ const selection = design.selection ?? {};
599
+ if (!selection.screenId) return [];
600
+ const screen = product.manifest.entries.screens
601
+ .map((entry) => product.entries[entry])
602
+ .find((candidate) => candidate.id === selection.screenId);
603
+ const presentation = screen?.presentations?.find(({ id }) => id === selection.presentationId);
604
+ const contextPlatform = Object.entries(selection.context ?? {})
605
+ .find(([axis]) => /platform|viewport|device/i.test(axis))?.[1];
606
+ const platform = String(presentation?.platform ?? contextPlatform ?? "").toLowerCase();
607
+ if (!platform) return [];
608
+ const hints = [];
609
+ const chosen = new Set(selection.themes ?? []);
610
+ const option = listTokenThemes(product, foundation)
611
+ .find((theme) => theme.name.toLowerCase() === platform && !chosen.has(theme.path));
612
+ if (option)
613
+ hints.push({
614
+ code: "platform_theme_available",
615
+ message: `This Presentation is ${platform}; theme option ${option.path} exists but is not selected. Pass --theme ${option.path} to check its values.`,
616
+ theme: option.path,
617
+ });
618
+ const components = componentLookup(workspace);
619
+ const visit = (node, path) => {
620
+ if (!node.visible) return;
621
+ const raw = design.projection.nodes[node.id];
622
+ const here = node === design.tree.root ? path : [...path, node.name];
623
+ const reference = instanceReference(node, design.projection);
624
+ if (reference && node !== design.tree.root) {
625
+ const set = components(reference);
626
+ const axis = set?.axes?.find((candidate) => PLATFORM_AXIS.test(candidate.name));
627
+ const value = axis ? (node.variant ?? raw.instance?.variant)?.[axis.id] : undefined;
628
+ const match = axis?.domain?.find((candidate) => String(candidate).toLowerCase() === platform);
629
+ if (value !== undefined && match !== undefined && String(value).toLowerCase() !== platform)
630
+ hints.push({
631
+ code: "platform_variant_mismatch",
632
+ nodeId: node.id,
633
+ where: here.join(" › "),
634
+ message: `A ${platform} Presentation uses ${axisLabel(axis, value)} of ${set.name}; ${axisLabel(axis, match)} exists.`,
635
+ });
636
+ return;
637
+ }
638
+ for (const child of node.children) visit(child, here);
639
+ };
640
+ visit(design.tree.root, []);
641
+ return hints;
642
+ }
643
+
644
+ export async function validateDesign(workspace, design) {
645
+ design = await resolveDesignLayout(workspace, design);
646
+ if (design.layoutSnapshot.status !== "resolved") {
647
+ const issues = design.layoutSnapshot.diagnostics.map(issue => ({ ...issue, severity: "warning" }));
648
+ const visibleCount = node => node.visible ? 1 + node.children.reduce((sum, child) => sum + visibleCount(child), 0) : 0;
649
+ return {
650
+ issues, issueCount: issues.length,
651
+ coverage: { complete: false, nodeCount: Object.keys(design.projection.nodes).length,
652
+ visibleNodeCount: visibleCount(design.tree.root), checks: [], skipped: [{ check: "native-layout",
653
+ reason: "Actual geometry is incomplete; seed bounds are not checked" }] },
654
+ };
655
+ }
656
+ const issues = (design.projection.diagnostics ?? []).map((diagnostic) => ({
657
+ ...diagnostic,
658
+ severity: "warning",
659
+ })),
660
+ skipped = [];
661
+ for (const diagnostic of design.projection.diagnostics ?? [])
662
+ if (diagnostic.code === "layout_projection_partial")
663
+ skipped.push({ check: "layout-reflow", ...diagnostic });
664
+ const nodes = design.projection.nodes;
665
+ const visibleIds = new Set();
666
+ const visible = (node) => {
667
+ if (!node.visible) return;
668
+ visibleIds.add(node.id);
669
+ for (const child of node.children) visible(child);
670
+ };
671
+ visible(design.tree.root);
672
+ if (!visibleIds.size)
673
+ skipped.push({
674
+ check: "visual",
675
+ reason: "The selected node is hidden by itself or an ancestor",
676
+ });
677
+ const metrics = {
678
+ items: design.render.textMetrics.filter(item => visibleIds.has(item.nodeId)),
679
+ diagnostics: design.layoutSnapshot.diagnostics,
680
+ };
681
+ for (const item of metrics.items) {
682
+ const node = nodes[item.nodeId];
683
+ if (item.missingGlyphs.length) {
684
+ skipped.push({
685
+ check: "text-fit",
686
+ nodeId: node.id,
687
+ reason: "missing-glyphs",
688
+ codepoints: item.missingGlyphs.map(
689
+ (char) => `U+${char.codePointAt(0).toString(16)}`,
690
+ ),
691
+ });
692
+ // Text in another language often needs another font; without one the
693
+ // PNG and text checks cannot see it.
694
+ issues.push({
695
+ code: "text_missing_glyphs",
696
+ severity: "warning",
697
+ nodeId: node.id,
698
+ message: `The font has no glyphs for ${[...new Set(item.missingGlyphs)].slice(0, 6).join(" ")}; import a font that covers this language with asset font import and use it in the text style`,
699
+ });
700
+ } else if (
701
+ // Sub-pixel line-height rounding is not an overflow; a clipped word
702
+ // or an extra line is.
703
+ (item.growType !== "auto-width" &&
704
+ item.width > node.width + Math.max(1, node.width * 0.02)) ||
705
+ (!["auto-width", "auto-height"].includes(item.growType) &&
706
+ item.height > node.height + Math.max(1.5, node.height * 0.08))
707
+ ) {
708
+ issues.push({
709
+ code: "text_overflow",
710
+ severity: "warning",
711
+ nodeId: node.id,
712
+ message: "Measured text exceeds its fixed box",
713
+ measured: { width: item.width, height: item.height },
714
+ box: { width: node.width, height: node.height },
715
+ });
716
+ }
717
+ }
718
+ for (const diagnostic of metrics.diagnostics)
719
+ skipped.push({ check: "text-fit", ...diagnostic });
720
+ const measuredText = new Map(metrics.items.filter((item) => !item.missingGlyphs.length).map((item) => [item.nodeId, item]));
721
+ // Where each node sits, by name, so an issue reads without its ID.
722
+ const places = new Map();
723
+ const painted = [];
724
+ const locate = (semantic, path) => {
725
+ if (!semantic.visible) return;
726
+ const here = semantic === design.tree.root ? path : [...path, semantic.name];
727
+ places.set(semantic.id, here);
728
+ painted.push(semantic);
729
+ for (const child of semantic.children) locate(child, here);
730
+ };
731
+ locate(design.tree.root, []);
732
+ const where = (id) => (places.get(id) ?? []).join(" › ") || design.tree.root.name;
733
+ const contains = (outer, inner) =>
734
+ outer.x <= inner.x + 0.5 &&
735
+ outer.y <= inner.y + 0.5 &&
736
+ outer.x + outer.width >= inner.x + inner.width - 0.5 &&
737
+ outer.y + outer.height >= inner.y + inner.height - 0.5;
738
+ // The opaque solid paint right under a text: the last node painted
739
+ // before it that covers it (an ancestor, or a shape behind it).
740
+ const backdrop = (semantic) => {
741
+ for (let index = painted.indexOf(semantic) - 1; index >= 0; index -= 1) {
742
+ const candidate = painted[index];
743
+ const raw = nodes[candidate.id];
744
+ if (raw.type === "TEXT" || !contains(candidate.bounds, semantic.bounds)) continue;
745
+ if ((raw.opacity ?? 1) !== 1) return undefined;
746
+ if (raw.fills?.length) return solidColor(raw);
747
+ }
748
+ return undefined;
749
+ };
750
+ const rootBounds = design.tree.root.bounds;
751
+ const outside = (bounds, parent) =>
752
+ bounds.x < parent.x - 0.5 ||
753
+ bounds.y < parent.y - 0.5 ||
754
+ bounds.x + bounds.width > parent.x + parent.width + 0.5 ||
755
+ bounds.y + bounds.height > parent.y + parent.height + 0.5;
756
+ const walk = (semantic, ancestors = [], holder = undefined) => {
757
+ if (!semantic.visible) return;
758
+ const node = nodes[semantic.id];
759
+ // A text that hugs grows with a longer text; past its container's edge
760
+ // it is cut (the box alone does not show that).
761
+ const measured = measuredText.get(semantic.id);
762
+ if (holder && node.type === "TEXT" && measured && node.growType === "auto-width" &&
763
+ measured.width > semantic.bounds.width + Math.max(1, semantic.bounds.width * 0.02) &&
764
+ semantic.bounds.x + measured.width > holder.bounds.x + holder.bounds.width + 0.5)
765
+ issues.push({
766
+ code: "text_overflow",
767
+ severity: "warning",
768
+ nodeId: node.id,
769
+ message: `Text needs ${Math.ceil(measured.width)}px and runs past ${holder.name}; give it room, let it wrap (width fill) or shorten it`,
770
+ measured: { width: measured.width, height: measured.height },
771
+ box: { width: semantic.bounds.width, height: semantic.bounds.height },
772
+ });
773
+ if (semantic !== design.tree.root && outside(semantic.bounds, rootBounds))
774
+ issues.push({
775
+ code: "outside_root",
776
+ severity: "warning",
777
+ nodeId: node.id,
778
+ message: "Node extends beyond the selected root",
779
+ bounds: semantic.bounds,
780
+ });
781
+ if (node.type === "TEXT" && node.text) {
782
+ const foreground = solidColor(node);
783
+ let background = backdrop(semantic);
784
+ if (!background)
785
+ for (const ancestor of [...design.ancestors].reverse()) {
786
+ if (ancestor.fills?.length) {
787
+ background = solidColor(ancestor);
788
+ break;
789
+ }
790
+ }
791
+ const rich = node.textBlocks?.some((block) =>
792
+ block.runs?.some((run) => run.fills || run.textStyle),
793
+ );
794
+ if (
795
+ foreground &&
796
+ background &&
797
+ !rich &&
798
+ ancestors.every((parent) => (parent.opacity ?? 1) === 1)
799
+ ) {
800
+ const a = luminance(foreground),
801
+ b = luminance(background);
802
+ const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
803
+ const size = node.textStyle?.fontSize ?? 14,
804
+ weight = Number(node.textStyle?.fontWeight ?? 400);
805
+ const minimum =
806
+ size >= 24 || (size >= 18.67 && weight >= 700) ? 3 : 4.5;
807
+ if (ratio < minimum)
808
+ issues.push({
809
+ code: "low_text_contrast",
810
+ severity: "warning",
811
+ nodeId: node.id,
812
+ ratio: Math.round(ratio * 100) / 100,
813
+ minimum,
814
+ message:
815
+ "Text contrast below the large/normal text threshold against the solid paint under it",
816
+ });
817
+ } else
818
+ skipped.push({
819
+ check: "text-contrast",
820
+ nodeId: node.id,
821
+ reason:
822
+ "requires opaque solid text and ancestor backgrounds without mixed runs",
823
+ });
824
+ }
825
+ const children = semantic.children.filter(({ visible }) => visible);
826
+ // Content wider or taller than its container runs past its edge (the
827
+ // root's own edge is outside_root). An auto layout says how much room
828
+ // its content needs.
829
+ if (semantic !== design.tree.root && children.length && ["FRAME", "COMPONENT"].includes(node.type)) {
830
+ const past = children.filter((child) => nodes[child.id]?.["layout-item-absolute"] !== true && outside(child.bounds, semantic.bounds));
831
+ if (past.length) {
832
+ const flex = node.layout === "flex";
833
+ const row = flex && !String(node["layout-flex-dir"] ?? "row").startsWith("column");
834
+ const inFlow = children.filter((child) => nodes[child.id]?.["layout-item-absolute"] !== true);
835
+ const gap = Number((row ? node["layout-gap"]?.["column-gap"] : node["layout-gap"]?.["row-gap"]) ?? 0);
836
+ const pad = node["layout-padding"] ?? {};
837
+ const needed = flex
838
+ ? Math.round(inFlow.reduce((sum, child) => sum + (row ? child.bounds.width : child.bounds.height), 0) + gap * Math.max(0, inFlow.length - 1) +
839
+ (row ? (pad.p2 ?? 0) + (pad.p4 ?? 0) : (pad.p1 ?? 0) + (pad.p3 ?? 0)))
840
+ : undefined;
841
+ const side = row ? "wide" : "tall";
842
+ const has = Math.round(row ? semantic.bounds.width : semantic.bounds.height);
843
+ issues.push({
844
+ code: "content_overflow",
845
+ severity: "warning",
846
+ nodeId: node.id,
847
+ message: flex && needed > has
848
+ ? `Its content needs ${needed}px ${side} but it is ${has}px; give it more room, let it hug, or make a child smaller`
849
+ : "Children run past its edge; give it more room or move them in",
850
+ past: past.map((child) => child.name),
851
+ });
852
+ }
853
+ }
854
+ for (let i = 0; i < children.length; i++)
855
+ for (let j = i + 1; j < children.length; j++) {
856
+ const a = children[i],
857
+ b = children[j];
858
+ if (
859
+ !["TEXT", "INSTANCE", "FRAME"].includes(a.type) ||
860
+ !["TEXT", "INSTANCE", "FRAME"].includes(b.type)
861
+ )
862
+ continue;
863
+ const width =
864
+ Math.min(a.bounds.x + a.bounds.width, b.bounds.x + b.bounds.width) -
865
+ Math.max(a.bounds.x, b.bounds.x);
866
+ const height =
867
+ Math.min(a.bounds.y + a.bounds.height, b.bounds.y + b.bounds.height) -
868
+ Math.max(a.bounds.y, b.bounds.y);
869
+ if (width > 1 && height > 1)
870
+ issues.push({
871
+ code: "possible_overlap",
872
+ severity: "info",
873
+ nodeId: a.id,
874
+ otherNodeId: b.id,
875
+ message: "Sibling bounds overlap; may be intentional",
876
+ area: width * height,
877
+ });
878
+ }
879
+ for (const child of children) walk(child, [...ancestors, node], semantic);
880
+ };
881
+ walk(design.tree.root, design.ancestors);
882
+ for (const issue of issues) {
883
+ if (issue.nodeId && places.has(issue.nodeId)) issue.where = where(issue.nodeId);
884
+ if (issue.otherNodeId && places.has(issue.otherNodeId)) issue.otherWhere = where(issue.otherNodeId);
885
+ const text = nodes[issue.nodeId]?.type === "TEXT" ? nodes[issue.nodeId].text : undefined;
886
+ if (text) issue.text = text.length > 40 ? `${text.slice(0, 39)}…` : text;
887
+ }
888
+ skipped.push({
889
+ check: "visual-intent",
890
+ reason:
891
+ "Automated geometry, text-fit and solid contrast cannot prove a design is visually correct; check the requested combinations and optionally PNG",
892
+ });
893
+ // One problem inside a component shows in every copy of it; it is fixed
894
+ // once, in the component, so it is reported once with its copies counted.
895
+ const grouped = [];
896
+ const byDefinition = new Map();
897
+ for (const issue of issues) {
898
+ const segments = String(issue.nodeId ?? "").split("__");
899
+ if (segments.length < 2 || issue.otherNodeId) {
900
+ grouped.push(issue);
901
+ continue;
902
+ }
903
+ // Copies differ in their text overrides, not in the definition at fault.
904
+ const key = [issue.code, segments.at(-1), issue.ratio ?? ""].join("\0");
905
+ const first = byDefinition.get(key);
906
+ if (!first) {
907
+ const entry = { ...issue, occurrences: 1 };
908
+ byDefinition.set(key, entry);
909
+ grouped.push(entry);
910
+ } else {
911
+ first.occurrences += 1;
912
+ if ((first.alsoAt ??= []).length < 2 && issue.where) first.alsoAt.push(issue.where);
913
+ }
914
+ }
915
+ for (const issue of grouped)
916
+ if (issue.occurrences === undefined) continue;
917
+ else if (issue.occurrences === 1) delete issue.occurrences;
918
+ else issue.message = `${issue.message} (inside a component: ${issue.occurrences} copies; fix it in the component)`;
919
+ issues.length = 0;
920
+ issues.push(...grouped);
921
+ const hints = platformHints(workspace, design);
922
+ return {
923
+ issues,
924
+ ...(hints.length ? { hints } : {}),
925
+ coverage: {
926
+ complete: false,
927
+ nodeCount: Object.keys(nodes).length,
928
+ visibleNodeCount: visibleIds.size,
929
+ checks: [
930
+ "text-fit",
931
+ "root-bounds",
932
+ "sibling-overlap",
933
+ "solid-text-contrast",
934
+ ],
935
+ skipped,
936
+ },
937
+ issueCount: issues.length,
938
+ };
939
+ }