@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,825 @@
1
+ import {
2
+ appliedTokenFields,
3
+ listTokenThemes,
4
+ resolveEffectiveToken,
5
+ tokenLibraryOf,
6
+ wireframeLayers,
7
+ } from "@smallpen/core";
8
+
9
+ const number = (value) =>
10
+ Number.isFinite(value) ? String(Math.round(value * 100) / 100) : "?";
11
+ const size = (bounds) => `${number(bounds?.width)}×${number(bounds?.height)}`;
12
+ const known = (bounds) =>
13
+ bounds &&
14
+ ["x", "y", "width", "height"].every((key) => Number.isFinite(bounds[key]));
15
+ const plain = (value) =>
16
+ typeof value === "string" ? value : JSON.stringify(value);
17
+
18
+ export function inspectionCoverage(coverage) {
19
+ const grouped = new Map();
20
+ for (const item of coverage.skipped) {
21
+ const key = JSON.stringify([item.check, item.reason, item.code]);
22
+ const entry = grouped.get(key);
23
+ if (entry) entry.count++;
24
+ else
25
+ grouped.set(key, {
26
+ check: item.check,
27
+ reason: item.reason,
28
+ code: item.code,
29
+ count: 1,
30
+ });
31
+ }
32
+ return {
33
+ ...coverage,
34
+ skipped: [...grouped.values()],
35
+ skippedCount: coverage.skipped.length,
36
+ };
37
+ }
38
+
39
+ export function inspectionTree(design, { full = false } = {}) {
40
+ const visit = (node) => ({
41
+ ...node,
42
+ children:
43
+ !full &&
44
+ node !== design.tree.root &&
45
+ (design.projection.nodes[node.id]?.type === "INSTANCE" ||
46
+ design.projection.nodes[node.id]?.instance)
47
+ ? []
48
+ : node.children.map(visit),
49
+ });
50
+ return { ...design.tree, root: visit(design.tree.root) };
51
+ }
52
+
53
+ // Raw values retain the sizing contract; native bounds supply actual values.
54
+ // A value equal to a Token is still a literal unless a binding exists.
55
+ export function inspectionReport(
56
+ workspace,
57
+ design,
58
+ { components, stored = {}, full = false } = {},
59
+ ) {
60
+ const snapshot = design.layoutSnapshot;
61
+ const projection = design.authoredProjection ?? design.projection;
62
+ const owners = [
63
+ workspace.product,
64
+ workspace.foundation,
65
+ ...(workspace.libraries ?? []),
66
+ ].filter(Boolean);
67
+ const ownerOf = (id) =>
68
+ owners.find((owner) => owner.manifest.packageId === id);
69
+ const ownerName = (owner) =>
70
+ owner?.manifest.name ?? owner?.manifest.packageId ?? "unknown package";
71
+ const ownerToken = (owner, id) => owner?.domain.tokens.get(id);
72
+ const layers = wireframeLayers(inspectionTree(design, { full }).root);
73
+ const references = new Map(
74
+ layers.map((layer) => [
75
+ layer.node.id,
76
+ String(layer.index).padStart(2, "0"),
77
+ ]),
78
+ );
79
+ const parents = new Map();
80
+ const index = (node) => {
81
+ node.children.forEach((child) => {
82
+ parents.set(child.id, node);
83
+ index(child);
84
+ });
85
+ };
86
+ index(design.tree.root);
87
+ const ancestry = (node) => {
88
+ const result = [];
89
+ for (
90
+ let parent = parents.get(node.id);
91
+ parent;
92
+ parent = parents.get(parent.id)
93
+ )
94
+ result.unshift(projection.nodes[parent.id]);
95
+ return [...(design.ancestors ?? []), ...result];
96
+ };
97
+ const componentInfo = (node) => {
98
+ const raw = projection.nodes[node.id];
99
+ const reference =
100
+ raw.componentRef ?? raw.instance?.component ?? node.component;
101
+ if (!reference && design.selection.componentId) {
102
+ const set = components?.({
103
+ assetId: design.selection.componentId,
104
+ packageId: design.selection.packageId,
105
+ });
106
+ const variant = set?.variants.find(
107
+ (item) => item.id === design.selection.variantId,
108
+ );
109
+ const source = variant?.nodes?.[raw.id];
110
+ return {
111
+ name: set?.name ?? "unknown shared component",
112
+ package: ownerName(ownerOf(design.selection.packageId)),
113
+ element: source?.name ?? "unknown source element",
114
+ role: "definition",
115
+ variant: (set?.axes ?? [])
116
+ .filter((axis) => variant?.selection?.[axis.id] !== undefined)
117
+ .map((axis) => `${axis.name}=${variant.selection[axis.id]}`),
118
+ overrides: [],
119
+ overridesKnown: Boolean(source),
120
+ ...(source
121
+ ? {}
122
+ : { status: "partial", reason: "component_definition_unavailable" }),
123
+ };
124
+ }
125
+ if (!reference) {
126
+ if (!raw.componentId && !raw.sourceNodeId) return null;
127
+ const owner = owners.find((candidate) =>
128
+ candidate.domain.locatedComponents?.has(raw.componentId),
129
+ );
130
+ const located = owner?.domain.locatedComponents.get(raw.componentId);
131
+ return {
132
+ name: located?.name ?? "unknown shared component",
133
+ package: ownerName(owner),
134
+ element: "unknown source element",
135
+ variant: [],
136
+ overrides: [],
137
+ overridesKnown: false,
138
+ status: "partial",
139
+ reason: "located_component_field_chain_not_exposed",
140
+ };
141
+ }
142
+ const set = components?.(reference);
143
+ const selection =
144
+ raw.variantSelection ?? raw.instance?.variant ?? node.variant ?? {};
145
+ const variant =
146
+ set?.variants.find((candidate) => candidate.id === raw.variantId) ??
147
+ set?.variants.find((candidate) =>
148
+ Object.entries(selection).every(
149
+ ([key, value]) => candidate.selection?.[key] === value,
150
+ ),
151
+ );
152
+ const source = variant?.nodes?.[raw.sourceNodeId ?? variant.rootId];
153
+ const copies = [...ancestry(node), raw].filter(
154
+ (candidate) => (stored[candidate.id] ?? candidate).instance,
155
+ );
156
+ const overrides = copies.flatMap((copy) =>
157
+ Object.entries((stored[copy.id] ?? copy).instance?.overrides ?? {})
158
+ .filter(
159
+ ([key]) =>
160
+ copy.id === node.id ||
161
+ key.slice(0, key.lastIndexOf(":")) === raw.sourceNodeId,
162
+ )
163
+ .map(([key, value]) => ({
164
+ element:
165
+ variant?.nodes?.[key.slice(0, key.lastIndexOf(":"))]?.name ??
166
+ "component element",
167
+ field: key.slice(key.lastIndexOf(":") + 1),
168
+ value,
169
+ })),
170
+ );
171
+ const placed = stored[raw.id];
172
+ if (placed?.instance && source) {
173
+ for (const name of [
174
+ "fills",
175
+ "strokes",
176
+ "opacity",
177
+ "visible",
178
+ "textStyle",
179
+ "tokenBindings",
180
+ ])
181
+ if (
182
+ Object.hasOwn(placed, name) &&
183
+ JSON.stringify(placed[name]) !== JSON.stringify(source[name])
184
+ )
185
+ overrides.push({
186
+ element: source.name,
187
+ field: name,
188
+ value: placed[name],
189
+ });
190
+ }
191
+ return {
192
+ name: set?.name ?? "unknown component",
193
+ package: ownerName(ownerOf(reference.packageId)),
194
+ element: source?.name ?? "unknown source element",
195
+ variant: (set?.axes ?? [])
196
+ .filter((axis) => selection[axis.id] !== undefined)
197
+ .map((axis) => `${axis.name}=${selection[axis.id]}`),
198
+ overrides,
199
+ // A located App copy may lack its full source chain. Absence of
200
+ // metadata is not proof that no overrides exist.
201
+ overridesKnown:
202
+ copies.length > 0 || Boolean(design.selection.componentId),
203
+ };
204
+ };
205
+ const resolveToken = (reference) => {
206
+ const resolution = resolveEffectiveToken(workspace.product, reference, {
207
+ ...workspace,
208
+ context: design.selection.context ?? {},
209
+ traceSources: true,
210
+ });
211
+ if (!resolution)
212
+ return {
213
+ status: "unknown",
214
+ path: "unknown Token",
215
+ reason: "token_value_unavailable",
216
+ };
217
+ const owner = ownerOf(resolution.sourcePackageId);
218
+ const library = tokenLibraryOf(owner);
219
+ const set = library?.sets.find((candidate) =>
220
+ candidate.tokens.some((item) => item.id === resolution.sourceTokenId),
221
+ );
222
+ return {
223
+ status: "resolved",
224
+ path:
225
+ ownerToken(ownerOf(reference.packageId), reference.assetId)?.path ??
226
+ resolution.token.path,
227
+ effectivePath: resolution.token.path,
228
+ value: resolution.value,
229
+ package: ownerName(owner),
230
+ ...(set ? { set: set.name } : {}),
231
+ themes: listTokenThemes(workspace.product, workspace.foundation)
232
+ .filter(
233
+ (theme) =>
234
+ theme.active &&
235
+ theme.packageId === resolution.sourcePackageId &&
236
+ (!set || theme.setIds.includes(set.id)),
237
+ )
238
+ .map((theme) => theme.path),
239
+ chain: (resolution.sourceChain ?? []).map((source) => ({
240
+ package: ownerName(ownerOf(source.packageId)),
241
+ path:
242
+ ownerToken(ownerOf(source.packageId), source.tokenId)?.path ??
243
+ "unknown Token",
244
+ role: source.role,
245
+ })),
246
+ aliases: (resolution.aliasChain ?? []).map((source) => {
247
+ const owner = ownerOf(source.packageId);
248
+ const set = tokenLibraryOf(owner)?.sets.find((candidate) =>
249
+ candidate.tokens.some((token) => token.id === source.tokenId),
250
+ );
251
+ return {
252
+ path: source.path,
253
+ package: ownerName(owner),
254
+ set: set?.name,
255
+ alias: source.alias,
256
+ contextRule: source.rule,
257
+ };
258
+ }),
259
+ };
260
+ };
261
+ const tokenCache = new Map();
262
+ const token = (reference) => {
263
+ const key = `${reference.packageId}\0${reference.assetId}`;
264
+ if (!tokenCache.has(key)) tokenCache.set(key, resolveToken(reference));
265
+ return tokenCache.get(key);
266
+ };
267
+ const field = (raw, component, name, value, aliases = [], fallback) => {
268
+ const keys = [name, ...aliases];
269
+ let reference = keys.map((key) => raw.tokenBindings?.[key]).find(Boolean);
270
+ if (!reference) {
271
+ const applied = appliedTokenFields(raw.appliedTokens);
272
+ const path = keys.map((key) => applied[key]).find(Boolean);
273
+ if (path) {
274
+ const owner = owners.find((candidate) =>
275
+ [...candidate.domain.tokens.values()].some(
276
+ (item) => item.path === path,
277
+ ),
278
+ );
279
+ const found =
280
+ owner &&
281
+ [...owner.domain.tokens.values()].find((item) => item.path === path);
282
+ if (found)
283
+ reference = {
284
+ packageId: owner.manifest.packageId,
285
+ assetId: found.id,
286
+ };
287
+ else
288
+ return {
289
+ value: value ?? fallback,
290
+ kind: "unknown",
291
+ path,
292
+ reason: "token_source_unavailable",
293
+ };
294
+ }
295
+ }
296
+ const overridden = component?.overrides.some(
297
+ (item) =>
298
+ item.element === component.element &&
299
+ (keys.includes(item.field) ||
300
+ (name === "fill" && item.field === "fills") ||
301
+ (name === "stroke" && item.field === "strokes") ||
302
+ (item.field === "textStyle" &&
303
+ Object.hasOwn(item.value ?? {}, name)) ||
304
+ (item.field === "tokenBindings" &&
305
+ keys.some((key) => Object.hasOwn(item.value ?? {}, key)))),
306
+ );
307
+ return {
308
+ value: value ?? fallback,
309
+ kind: reference ? "token" : value === undefined ? "default" : "literal",
310
+ ...(reference ? { token: token(reference) } : {}),
311
+ source:
312
+ component?.status === "partial"
313
+ ? `unknown (${component.reason})`
314
+ : overridden
315
+ ? `instance override → ${component.name} / ${component.element}`
316
+ : component
317
+ ? `${component.name} / ${component.element}`
318
+ : "page",
319
+ };
320
+ };
321
+ const metrics = new Map(
322
+ (design.render?.textMetrics ?? []).map((item) => [item.nodeId, item]),
323
+ );
324
+ const interactionRule = (rule) => {
325
+ const destination = rule.destination ?? rule.target;
326
+ const location =
327
+ typeof destination === "string"
328
+ ? workspace.product.runtime.reverseNodes?.[destination]
329
+ : destination;
330
+ const screen =
331
+ location &&
332
+ workspace.product.manifest.entries.screens
333
+ .map((entry) => workspace.product.entries[entry])
334
+ .find((candidate) => candidate.id === location.screenId);
335
+ const presentation = screen?.presentations.find(
336
+ (candidate) => candidate.id === location.presentationId,
337
+ );
338
+ return {
339
+ on: rule["event-type"] ?? rule.trigger ?? "unspecified",
340
+ action: rule["action-type"] ?? rule.type ?? rule.action ?? "unspecified",
341
+ ...(destination
342
+ ? {
343
+ destination: screen
344
+ ? {
345
+ page: screen.name,
346
+ ...(presentation
347
+ ? { platform: presentation.platform ?? presentation.name }
348
+ : {}),
349
+ }
350
+ : { status: "unknown", reason: "destination_name_unavailable" },
351
+ }
352
+ : {}),
353
+ ...(rule.url ? { url: rule.url } : {}),
354
+ ...(rule.delay !== undefined ? { delay: rule.delay } : {}),
355
+ };
356
+ };
357
+ const nodes = layers.map(({ node, depth, index }) => {
358
+ const raw = projection.nodes[node.id];
359
+ const component = componentInfo(node);
360
+ const actual = snapshot.nodes[node.id];
361
+ const parent = parents.get(node.id);
362
+ const parentBounds =
363
+ parent?.bounds ?? snapshot.nodes[design.ancestors?.at(-1)?.id]?.bounds;
364
+ const fields = {};
365
+ const add = (name, value, aliases, fallback) =>
366
+ (fields[name] = field(raw, component, name, value, aliases, fallback));
367
+ const sizing = (axis, mode) =>
368
+ mode === "fill"
369
+ ? "fill"
370
+ : mode === "auto"
371
+ ? "hug"
372
+ : raw.type === "TEXT" &&
373
+ (raw.growType === "auto-width" ||
374
+ (axis === "height" && raw.growType === "auto-height"))
375
+ ? "hug"
376
+ : "fixed";
377
+ const constraints = {
378
+ width: sizing("width", raw["layout-item-h-sizing"]),
379
+ height: sizing("height", raw["layout-item-v-sizing"]),
380
+ position: raw["layout-item-absolute"] ? "absolute" : "flow",
381
+ horizontal: raw.horizontalConstraint ?? "MIN",
382
+ vertical: raw.verticalConstraint ?? "MIN",
383
+ };
384
+ for (const axis of ["width", "height"])
385
+ if (constraints[axis] === "fixed" || raw.tokenBindings?.[axis])
386
+ add(axis, raw[axis]);
387
+ for (const [key, binding] of [
388
+ ["min-w", "minWidth"],
389
+ ["max-w", "maxWidth"],
390
+ ["min-h", "minHeight"],
391
+ ["max-h", "maxHeight"],
392
+ ])
393
+ if (raw[`layout-item-${key}`] !== undefined)
394
+ add(binding, raw[`layout-item-${key}`]);
395
+ let layout;
396
+ if (raw.layout) {
397
+ const direction = raw["layout-flex-dir"] ?? "row";
398
+ const column = direction.startsWith("column");
399
+ layout = {
400
+ type: raw.layout,
401
+ direction,
402
+ justify: raw["layout-justify-content"] ?? "start",
403
+ align: raw["layout-align-items"] ?? "start",
404
+ wrap: raw["layout-wrap-type"] ?? "nowrap",
405
+ };
406
+ add(
407
+ "gap",
408
+ raw["layout-gap"]?.[column ? "row-gap" : "column-gap"],
409
+ ["itemSpacing", column ? "rowGap" : "columnGap"],
410
+ 0,
411
+ );
412
+ for (const [side, key] of [
413
+ ["Top", "p1"],
414
+ ["Right", "p2"],
415
+ ["Bottom", "p3"],
416
+ ["Left", "p4"],
417
+ ])
418
+ add(`padding${side}`, raw["layout-padding"]?.[key], [], 0);
419
+ const children = node.children.filter(
420
+ (child) =>
421
+ child.visible &&
422
+ !projection.nodes[child.id]?.["layout-item-absolute"],
423
+ );
424
+ if (!direction.endsWith("reverse")) children.reverse();
425
+ const transformed = [
426
+ ...ancestry(node),
427
+ raw,
428
+ ...children.map((child) => projection.nodes[child.id]),
429
+ ].some((item) => item.rotation || item.flipX || item.flipY);
430
+ if (raw.layout !== "flex" || layout.wrap !== "nowrap")
431
+ layout.spacingUnknown = "wrapped_or_grid_spacing_not_exposed";
432
+ layout.spaces =
433
+ raw.layout === "flex" && layout.wrap === "nowrap"
434
+ ? children.slice(1).map((child, i) => {
435
+ const a = children[i].bounds,
436
+ b = child.bounds;
437
+ const start = column ? "y" : "x",
438
+ extent = column ? "height" : "width";
439
+ const distance =
440
+ !transformed && known(a) && known(b)
441
+ ? direction.endsWith("reverse")
442
+ ? a[start] - b[start] - b[extent]
443
+ : b[start] - a[start] - a[extent]
444
+ : null;
445
+ return {
446
+ from: children[i].name,
447
+ to: child.name,
448
+ actual: distance,
449
+ source:
450
+ layout.justify === "space-between"
451
+ ? "space-between allocation (includes gap and margins)"
452
+ : "resolved edge distance (includes gap and margins)",
453
+ ...(distance === null
454
+ ? {
455
+ reason: transformed
456
+ ? "transformed_spacing_not_exposed"
457
+ : "native_bounds_unavailable",
458
+ }
459
+ : {}),
460
+ };
461
+ })
462
+ : [];
463
+ }
464
+ const margin = raw["layout-item-margin"];
465
+ if (margin)
466
+ for (const [side, key] of [
467
+ ["Top", "m1"],
468
+ ["Right", "m2"],
469
+ ["Bottom", "m3"],
470
+ ["Left", "m4"],
471
+ ])
472
+ add(`margin${side}`, margin[key], [], 0);
473
+ const visible = add("visible", raw.visible, [], true);
474
+ for (const [name, value, aliases] of [
475
+ ["fill", raw.fills?.[0], ["fills.0"]],
476
+ ["stroke", raw.strokes?.[0], ["strokes.0"]],
477
+ ["opacity", raw.opacity],
478
+ ["rotation", raw.rotation],
479
+ ["shadow", raw.shadow],
480
+ ["hideFillOnExport", raw["hide-fill-on-export"]],
481
+ ])
482
+ if (value !== undefined) add(name, value, aliases);
483
+ if (raw.cornerRadius !== undefined)
484
+ for (const [i, corner] of [
485
+ "TopLeft",
486
+ "TopRight",
487
+ "BottomRight",
488
+ "BottomLeft",
489
+ ].entries())
490
+ add(
491
+ `radius${corner}`,
492
+ Array.isArray(raw.cornerRadius)
493
+ ? raw.cornerRadius[i]
494
+ : raw.cornerRadius,
495
+ ["cornerRadius"],
496
+ );
497
+ let text;
498
+ if (raw.type === "TEXT") {
499
+ add("text", raw.text, [], "");
500
+ for (const [key, value] of Object.entries(raw.textStyle ?? {}))
501
+ add(key, value, ["typography"]);
502
+ const measured = metrics.get(node.id);
503
+ text = {
504
+ value: raw.text ?? "",
505
+ growType: raw.growType ?? "fixed",
506
+ wrapping:
507
+ raw.growType === "auto-width"
508
+ ? "unconstrained width"
509
+ : "constrained by resolved width",
510
+ measured: measured
511
+ ? { width: measured.width, height: measured.height }
512
+ : null,
513
+ primaryFaces: [
514
+ ...new Map(
515
+ (design.render?.fontRequests ?? [])
516
+ .filter((face) => face.nodeId === node.id)
517
+ .map(({ nodeId, ...face }) => [JSON.stringify(face), face]),
518
+ ).values(),
519
+ ],
520
+ glyphFallback: {
521
+ status: "unknown",
522
+ reason: "native_per_glyph_font_not_exposed",
523
+ },
524
+ lines: { status: "unknown", reason: "native_line_count_not_exposed" },
525
+ truncation: {
526
+ status: "unspecified",
527
+ reason: "no_explicit_ellipsis_rule",
528
+ },
529
+ };
530
+ }
531
+ const interactions =
532
+ (design.renderProjection ?? design.projection).presentation
533
+ ?.interactions ?? [];
534
+ const defined = [
535
+ ...(raw.interactions ?? []),
536
+ ...interactions.filter(
537
+ (item) => item.nodeId === node.id || item.sourceNodeId === node.id,
538
+ ),
539
+ ];
540
+ return {
541
+ ref: String(index).padStart(2, "0"),
542
+ nodeId: node.id,
543
+ name: node.name,
544
+ depth,
545
+ geometry: {
546
+ ...actual,
547
+ coordinateSpace: "page",
548
+ relativeToParent:
549
+ known(node.bounds) && known(parentBounds)
550
+ ? {
551
+ x: node.bounds.x - parentBounds.x,
552
+ y: node.bounds.y - parentBounds.y,
553
+ }
554
+ : null,
555
+ },
556
+ constraints,
557
+ ...(layout ? { layout } : {}),
558
+ fields,
559
+ source: component
560
+ ? { kind: "component", ...component }
561
+ : { kind: "page" },
562
+ visibility: {
563
+ value: node.visible,
564
+ source: visible,
565
+ ...(!node.visible && raw.visible !== false
566
+ ? { reason: "hidden by ancestor" }
567
+ : {}),
568
+ },
569
+ interaction: defined.length
570
+ ? {
571
+ status: "declared",
572
+ rules: defined,
573
+ readable: defined.map(interactionRule),
574
+ }
575
+ : { status: "unspecified" },
576
+ disabledReason: {
577
+ status: "unspecified",
578
+ reason: "no_declared_business_rule",
579
+ },
580
+ ...(text ? { text } : {}),
581
+ };
582
+ });
583
+ const refOf = (id) => {
584
+ for (let current = id; current; current = parents.get(current)?.id)
585
+ if (references.has(current)) return references.get(current);
586
+ return null;
587
+ };
588
+ const issues = (design.validation?.issues ?? snapshot.diagnostics).map(
589
+ (issue) => ({
590
+ ...issue,
591
+ ...(issue.nodeId ? { ref: refOf(issue.nodeId) } : {}),
592
+ ...(issue.otherNodeId ? { otherRef: refOf(issue.otherNodeId) } : {}),
593
+ }),
594
+ );
595
+ for (const node of nodes)
596
+ if (node.source.status === "partial")
597
+ issues.push({
598
+ ref: node.ref,
599
+ nodeId: node.nodeId,
600
+ code: "component_source_unavailable",
601
+ severity: "warning",
602
+ message: `Component field provenance is unknown: ${node.source.reason}`,
603
+ });
604
+ for (const node of nodes)
605
+ for (const [name, value] of Object.entries(node.fields))
606
+ if (value.kind === "unknown" || value.token?.status === "unknown")
607
+ issues.push({
608
+ ref: node.ref,
609
+ nodeId: node.nodeId,
610
+ field: name,
611
+ code: "token_source_unavailable",
612
+ severity: "warning",
613
+ message: `${name}: ${value.path ?? value.token?.path ?? "Token"} has no known source`,
614
+ });
615
+ const screen = workspace.product.manifest.entries.screens
616
+ .map((entry) => workspace.product.entries[entry])
617
+ .find((candidate) => candidate.id === design.selection.screenId);
618
+ const presentation = screen?.presentations.find(
619
+ (candidate) => candidate.id === design.selection.presentationId,
620
+ );
621
+ const set =
622
+ design.selection.componentId &&
623
+ components?.({
624
+ packageId: design.selection.packageId,
625
+ assetId: design.selection.componentId,
626
+ });
627
+ const target = {
628
+ ...(screen
629
+ ? {
630
+ page: screen.name,
631
+ platform: presentation?.platform ?? presentation?.name,
632
+ }
633
+ : {}),
634
+ ...(set
635
+ ? {
636
+ component: set.name,
637
+ variant: (set.axes ?? [])
638
+ .map((axis) => {
639
+ const chosen = set.variants.find(
640
+ (variant) => variant.id === design.selection.variantId,
641
+ )?.selection;
642
+ return chosen?.[axis.id] !== undefined
643
+ ? `${axis.name}=${chosen[axis.id]}`
644
+ : undefined;
645
+ })
646
+ .filter(Boolean)
647
+ .join(" · "),
648
+ }
649
+ : {}),
650
+ ...(design.selection.nodeId ? { element: design.tree.root.name } : {}),
651
+ };
652
+ return {
653
+ snapshot: { ...snapshot, nodes: undefined, target },
654
+ nodes,
655
+ issues,
656
+ coverage: {
657
+ ...(design.validation?.coverage ?? {
658
+ checks: ["native-layout"],
659
+ skipped: [],
660
+ }),
661
+ selection: design.selection,
662
+ viewport: snapshot.viewport,
663
+ combinations: 1,
664
+ otherSizesAndStates: "not checked",
665
+ },
666
+ };
667
+ }
668
+
669
+ function fieldText(field) {
670
+ const value =
671
+ typeof field.value === "object"
672
+ ? plain(field.value)
673
+ : String(field.value ?? "?");
674
+ if (field.kind === "token") {
675
+ const token = field.token;
676
+ const source =
677
+ token.chain?.length > 1
678
+ ? token.chain
679
+ .map((item) => `${item.package} / ${item.path}`)
680
+ .join(" → ")
681
+ : token.package;
682
+ const aliases =
683
+ token.aliases
684
+ ?.slice(1)
685
+ .map(
686
+ (item) =>
687
+ `${item.path} (${item.package}${item.set ? ` / ${item.set}` : ""})`,
688
+ ) ?? [];
689
+ return `${value} {${token.path}} ← ${source}${token.set ? ` / ${token.set}` : ""}${aliases.length ? ` → ${aliases.join(" → ")}` : ""}`;
690
+ }
691
+ return `${value} · ${field.kind}${field.kind === "unknown" ? ` (${field.reason})` : ""}`;
692
+ }
693
+
694
+ export function inspectionText(report, { details = false } = {}) {
695
+ const { snapshot } = report;
696
+ const lines = [
697
+ `SPEC · px · coordinates: page; local coordinates: parent top-left (page axes)`,
698
+ `Target: ${
699
+ Object.entries(snapshot.target)
700
+ .filter(([, value]) => value !== undefined)
701
+ .map(([key, value]) => `${key}=${value}`)
702
+ .join(" · ") ||
703
+ report.nodes[0]?.name ||
704
+ "selected design"
705
+ }`,
706
+ `Theme: ${snapshot.selection.themes?.join(" + ") || "defaults"} · viewport ${size(snapshot.viewport)}`,
707
+ `Geometry: ${snapshot.status} · renderer: ${snapshot.renderer} · revision: ${snapshot.revision}`,
708
+ `Snapshot: ${snapshot.id}`,
709
+ "Use constraints for implementation; PNG for appearance; wireframe for spatial orientation.",
710
+ ];
711
+ for (const node of report.nodes) {
712
+ const bounds = node.geometry.bounds;
713
+ const layout = node.geometry.layoutBox;
714
+ const indent = " ".repeat(node.depth);
715
+ lines.push(
716
+ `${indent}[${node.ref}] ${node.name}${node.source.kind === "component" ? ` → ${node.source.name}${node.source.variant.length ? ` (${node.source.variant.join(" · ")})` : ""}` : ""}${node.visibility.value ? "" : " · hidden"}`,
717
+ );
718
+ lines.push(
719
+ `${indent} x=${number(bounds?.x)}, y=${number(bounds?.y)} · width=${node.constraints.width} → ${number(layout?.width)}px · height=${node.constraints.height} → ${number(layout?.height)}px${node.geometry.status === "partial" ? ` · partial (${node.geometry.reason})` : ""}`,
720
+ );
721
+ if (node.geometry.relativeToParent)
722
+ lines.push(
723
+ `${indent} local: x=${number(node.geometry.relativeToParent.x)}, y=${number(node.geometry.relativeToParent.y)}`,
724
+ );
725
+ for (const axis of ["width", "height"])
726
+ if (node.fields[axis]?.kind === "token")
727
+ lines.push(
728
+ `${indent} ${axis} constraint: ${fieldText(node.fields[axis])}`,
729
+ );
730
+ const margins = Object.entries(node.fields).filter(
731
+ ([key, value]) =>
732
+ key.startsWith("margin") && (value.value || value.kind === "token"),
733
+ );
734
+ if (margins.length)
735
+ lines.push(
736
+ `${indent} margin: ${margins.map(([key, value]) => `${key}=${fieldText(value)}`).join(" / ")}`,
737
+ );
738
+ if (node.fields.hideFillOnExport?.value)
739
+ lines.push(
740
+ `${indent} export rule: hide fill; preview keeps the authored fill`,
741
+ );
742
+ if (node.layout) {
743
+ lines.push(
744
+ `${indent} layout: ${node.layout.type === "flex" ? node.layout.direction : node.layout.type} · gap=${fieldText(node.fields.gap)} · justify=${node.layout.justify} · align=${node.layout.align} · ${node.layout.wrap}`,
745
+ );
746
+ lines.push(
747
+ `${indent} padding T/R/B/L: ${["Top", "Right", "Bottom", "Left"].map((side) => fieldText(node.fields[`padding${side}`])).join(" / ")}`,
748
+ );
749
+ for (const space of node.layout.spaces)
750
+ lines.push(
751
+ `${indent} ${space.from} ↔ ${space.to}: ${number(space.actual)}px · ${space.source}${space.reason ? ` · partial (${space.reason})` : ""}`,
752
+ );
753
+ if (node.layout.spacingUnknown)
754
+ lines.push(
755
+ `${indent} actual inter-item spacing: ? · partial (${node.layout.spacingUnknown})`,
756
+ );
757
+ }
758
+ if (node.source.kind === "component") {
759
+ lines.push(
760
+ `${indent} source: ${node.source.package} / ${node.source.name} / ${node.source.element}${node.source.role === "definition" ? " · shared definition" : ""}`,
761
+ );
762
+ if (node.source.status === "partial")
763
+ lines.push(`${indent} source chain: partial (${node.source.reason})`);
764
+ lines.push(
765
+ `${indent} page overrides: ${node.source.overrides.length ? node.source.overrides.map((item) => `${item.element}.${item.field}=${plain(item.value)}`).join("; ") : node.source.role === "definition" ? "not applicable (shared definition)" : node.source.overridesKnown ? "none" : "unknown (source chain unavailable)"}`,
766
+ );
767
+ }
768
+ if (node.fields.visible.kind === "token" || !node.visibility.value)
769
+ lines.push(
770
+ `${indent} visible constraint: ${fieldText(node.fields.visible)} → ${node.visibility.value ? "visible" : "hidden"}${node.visibility.reason ? ` (${node.visibility.reason})` : ""}`,
771
+ );
772
+ if (node.text) {
773
+ const content =
774
+ !details && node.text.value.length > 120
775
+ ? `${node.text.value.slice(0, 120)}… (+${node.text.value.length - 120} chars; expand --element)`
776
+ : node.text.value;
777
+ lines.push(
778
+ `${indent} text: ${JSON.stringify(content)} · ${node.text.wrapping} · lines=? (${node.text.lines.reason}) · ellipsis: unspecified`,
779
+ );
780
+ }
781
+ if (node.interaction.status === "declared")
782
+ lines.push(`${indent} interaction: ${plain(node.interaction.readable)}`);
783
+ if (details) {
784
+ if (known(bounds))
785
+ lines.push(
786
+ `${indent} axis-aligned bounds: ${size(bounds)} at ${number(bounds.x)},${number(bounds.y)}`,
787
+ );
788
+ if (node.text)
789
+ lines.push(
790
+ `${indent} App primary faces: ${
791
+ node.text.primaryFaces.length
792
+ ? node.text.primaryFaces
793
+ .map((face) => `${face.family} ${face.weight} ${face.style}`)
794
+ .join(", ")
795
+ : "? (native font plan unavailable)"
796
+ } · per-glyph fallback: ? (${node.text.glyphFallback.reason})`,
797
+ );
798
+ for (const [name, value] of Object.entries(node.fields)) {
799
+ if (/^(padding|gap$|visible$)/.test(name)) continue;
800
+ lines.push(
801
+ `${indent} ${name}: ${fieldText(value)} · from ${value.source}`,
802
+ );
803
+ }
804
+ lines.push(
805
+ `${indent} disabled reason: unspecified (no declared business rule)`,
806
+ );
807
+ if (node.interaction.status === "unspecified")
808
+ lines.push(`${indent} interaction: unspecified`);
809
+ }
810
+ for (const issue of report.issues.filter((item) => item.ref === node.ref))
811
+ lines.push(
812
+ `${indent} ! ${issue.code}${issue.where ? ` (${issue.where})` : ""}: ${issue.message}${issue.otherRef ? ` [${issue.otherRef}]` : ""}`,
813
+ );
814
+ }
815
+ for (const issue of report.issues.filter((item) => !item.ref))
816
+ lines.push(`! ${issue.code}: ${issue.message}`);
817
+ lines.push(
818
+ `Coverage: 1 selected combination · ${report.coverage.checks.join(", ")} · other sizes/states: not checked`,
819
+ );
820
+ for (const skipped of inspectionCoverage(report.coverage).skipped)
821
+ lines.push(
822
+ `Skipped: ${skipped.check} ×${skipped.count} · ${skipped.reason ?? skipped.code}`,
823
+ );
824
+ return lines.join("\n");
825
+ }