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

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