@smallpen/core 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,825 @@
1
+ import { resolveContext } from "./contexts.mjs";
2
+ import { projectScenario, projectScreen } from "./design-projection.mjs";
3
+ import { fail } from "./errors.mjs";
4
+
5
+ const VIEW_FORMATS = ["structure", "semantic", "wireframe", "screenshot"];
6
+ const DESIGN_SELECTOR_FIELDS = new Set([
7
+ "context",
8
+ "contextProfileId",
9
+ "presentationId",
10
+ "scenarioId",
11
+ "screenId",
12
+ "viewFormat",
13
+ ]);
14
+
15
+ function designSelector(value) {
16
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
17
+ fail("invalid_design_selector", "Design selector must contain an object");
18
+ }
19
+ const fields = Object.keys(value)
20
+ .filter((field) => !DESIGN_SELECTOR_FIELDS.has(field))
21
+ .sort();
22
+ if (fields.length > 0) {
23
+ fail(
24
+ "unknown_design_selector_field",
25
+ `Design selector contains unknown fields: ${fields.join(", ")}`,
26
+ { fields, validFields: [...DESIGN_SELECTOR_FIELDS].sort() },
27
+ );
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function screenById(snapshot, screenId) {
33
+ const entry = snapshot.manifest.entries.screens.find(
34
+ (candidate) => snapshot.entries[candidate].id === screenId,
35
+ );
36
+ return entry ? snapshot.entries[entry] : undefined;
37
+ }
38
+
39
+ function workspaceProfiles(product, foundation) {
40
+ const profiles = new Map(foundation?.domain.contextProfiles ?? []);
41
+ for (const [id, profile] of product.domain.contextProfiles) {
42
+ if (profiles.has(id)) {
43
+ fail(
44
+ "duplicate_workspace_context_profile",
45
+ `Product cannot redefine Foundation Context profile ${id}`,
46
+ { path: `contexts.${id}`, profileId: id },
47
+ );
48
+ }
49
+ profiles.set(id, profile);
50
+ }
51
+ return profiles;
52
+ }
53
+
54
+ function scenarioSelection(product, selector) {
55
+ if (!selector.scenarioId) return undefined;
56
+ const scenario = product.domain.scenarios.get(selector.scenarioId);
57
+ if (!scenario) {
58
+ fail("unknown_scenario", `Unknown Scenario: ${selector.scenarioId}`, {
59
+ path: "scenarioId",
60
+ });
61
+ }
62
+ return scenario;
63
+ }
64
+
65
+ export function resolveDesignView(product, options = {}) {
66
+ const selector = designSelector(options.selector ?? {});
67
+ const scenario = scenarioSelection(product, selector);
68
+ const scenarioTarget = scenario?.target;
69
+ if (
70
+ selector.screenId &&
71
+ scenarioTarget &&
72
+ (scenarioTarget.kind !== "screen" ||
73
+ selector.screenId !== scenarioTarget.screen.assetId)
74
+ ) {
75
+ fail("selector_conflict", "Scenario and Screen selectors disagree", {
76
+ path: "screenId",
77
+ });
78
+ }
79
+ if (
80
+ selector.presentationId &&
81
+ scenarioTarget &&
82
+ (scenarioTarget.kind !== "screen" ||
83
+ selector.presentationId !== scenarioTarget.presentationId)
84
+ ) {
85
+ fail(
86
+ "selector_conflict",
87
+ "Scenario and Presentation selectors disagree",
88
+ { path: "presentationId" },
89
+ );
90
+ }
91
+ const componentScenario =
92
+ scenarioTarget?.kind === "component" ? scenario : undefined;
93
+ if (componentScenario) {
94
+ // Component Scenarios resolve a component-targeted design view without a
95
+ // Screen; the projection renders the selected variant of the component.
96
+ const profiles = workspaceProfiles(product, options.foundation);
97
+ const profile = selector.contextProfileId
98
+ ? profiles.get(selector.contextProfileId)
99
+ : undefined;
100
+ if (selector.contextProfileId && !profile) {
101
+ fail(
102
+ "unknown_context_profile",
103
+ `Unknown Context profile: ${selector.contextProfileId}`,
104
+ { path: "contextProfileId" },
105
+ );
106
+ }
107
+ const context = resolveContext(product, options.foundation, {
108
+ ...(profile?.values ?? {}),
109
+ ...(scenario?.context ?? {}),
110
+ ...(selector.context ?? {}),
111
+ });
112
+ const viewFormat = selector.viewFormat ?? "structure";
113
+ if (!VIEW_FORMATS.includes(viewFormat)) {
114
+ fail("unknown_view_format", `Unknown View Format: ${viewFormat}`, {
115
+ path: "viewFormat",
116
+ });
117
+ }
118
+ return {
119
+ scenarioId: scenario.id,
120
+ selection: {
121
+ context,
122
+ ...(scenario ? { scenarioId: scenario.id } : {}),
123
+ viewFormat,
124
+ },
125
+ };
126
+ }
127
+ const screenId =
128
+ selector.screenId ??
129
+ scenarioTarget?.screen.assetId ??
130
+ product.manifest.defaultScreenId;
131
+ if (!screenId) {
132
+ fail(
133
+ "missing_default_screen",
134
+ "Package has no default Screen",
135
+ { path: "manifest.json.defaultScreenId" },
136
+ );
137
+ }
138
+ const screen = screenById(product, screenId);
139
+ if (!screen) {
140
+ fail("unknown_screen", `Unknown Screen: ${screenId}`, { path: "screenId" });
141
+ }
142
+ const presentationId =
143
+ selector.presentationId ?? scenarioTarget?.presentationId ?? screen.basePresentationId;
144
+ const presentation = screen.presentations.find(({ id }) => id === presentationId);
145
+ if (!presentation) {
146
+ fail(
147
+ "unknown_presentation",
148
+ `Unknown Presentation on Screen ${screenId}: ${presentationId}`,
149
+ { path: "presentationId" },
150
+ );
151
+ }
152
+ const profiles = workspaceProfiles(product, options.foundation);
153
+ const profile = selector.contextProfileId
154
+ ? profiles.get(selector.contextProfileId)
155
+ : undefined;
156
+ if (selector.contextProfileId && !profile) {
157
+ fail(
158
+ "unknown_context_profile",
159
+ `Unknown Context profile: ${selector.contextProfileId}`,
160
+ { path: "contextProfileId" },
161
+ );
162
+ }
163
+ const context = resolveContext(product, options.foundation, {
164
+ ...(profile?.values ?? {}),
165
+ ...(scenario?.context ?? {}),
166
+ ...(selector.context ?? {}),
167
+ });
168
+ const viewFormat = selector.viewFormat ?? "structure";
169
+ if (!VIEW_FORMATS.includes(viewFormat)) {
170
+ fail("unknown_view_format", `Unknown View Format: ${viewFormat}`, {
171
+ path: "viewFormat",
172
+ });
173
+ }
174
+ return {
175
+ presentation: structuredClone(presentation),
176
+ scenarioId: scenario?.id,
177
+ screen: structuredClone(screen),
178
+ selection: {
179
+ context,
180
+ presentationId,
181
+ ...(scenario ? { scenarioId: scenario.id } : {}),
182
+ screenId,
183
+ viewFormat,
184
+ },
185
+ };
186
+ }
187
+
188
+ function labels(locale) {
189
+ return locale?.toLowerCase().startsWith("zh")
190
+ ? {
191
+ context: "外观设置",
192
+ presentation: "页面稿",
193
+ scenario: "设计状态",
194
+ "view-format": "查看方式",
195
+ }
196
+ : {
197
+ context: "Design Context",
198
+ presentation: "Presentation",
199
+ scenario: "Scenario",
200
+ "view-format": "View Format",
201
+ };
202
+ }
203
+
204
+ function command(args) {
205
+ return ["smallpen", "read-view", "<package.smallpen>"]
206
+ .concat(
207
+ Object.entries(args).flatMap(([name, rawValue]) =>
208
+ (Array.isArray(rawValue) ? rawValue : [rawValue]).flatMap((value) => [
209
+ `--${name}`,
210
+ String(value),
211
+ ]),
212
+ ),
213
+ )
214
+ .map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
215
+ .join(" ");
216
+ }
217
+
218
+ function selectionArguments(selection, options = {}) {
219
+ return {
220
+ ...(options.context === false
221
+ ? {}
222
+ : {
223
+ context: Object.entries(selection.context)
224
+ .sort(([left], [right]) => left.localeCompare(right))
225
+ .map(([axis, value]) => `${axis}=${value}`),
226
+ }),
227
+ format: selection.viewFormat,
228
+ presentation: selection.presentationId,
229
+ ...(options.scenario === false || !selection.scenarioId
230
+ ? {}
231
+ : { scenario: selection.scenarioId }),
232
+ screen: selection.screenId,
233
+ };
234
+ }
235
+
236
+ function discoveryEntry(kind, id, name, label, args, metadata = {}) {
237
+ return {
238
+ args,
239
+ command: { args, operation: "smallpen.read" },
240
+ copyableCommand: command(args),
241
+ id,
242
+ kind,
243
+ label,
244
+ name,
245
+ nextOperation: "smallpen.read",
246
+ selectorParameters: Object.keys(args),
247
+ summary: name,
248
+ ...metadata,
249
+ };
250
+ }
251
+
252
+ export function createDiscoveryGuide(product, resolved, options = {}) {
253
+ const localized = labels(options.locale);
254
+ const entries = [];
255
+ for (const presentation of resolved.screen?.presentations ?? []) {
256
+ if (presentation.id === resolved.selection.presentationId) continue;
257
+ entries.push(
258
+ discoveryEntry(
259
+ "presentation",
260
+ presentation.id,
261
+ presentation.name,
262
+ localized.presentation,
263
+ {
264
+ ...selectionArguments(resolved.selection, { scenario: false }),
265
+ presentation: presentation.id,
266
+ },
267
+ {
268
+ defaults: { presentation: resolved.screen.basePresentationId },
269
+ validValues: resolved.screen.presentations.map(({ id }) => id),
270
+ },
271
+ ),
272
+ );
273
+ }
274
+ const axes = new Map(options.foundation?.domain.contextAxes ?? []);
275
+ for (const [id, axis] of product.domain.contextAxes) axes.set(id, axis);
276
+ for (const axis of [...axes.values()].sort((left, right) =>
277
+ left.id.localeCompare(right.id),
278
+ )) {
279
+ for (const value of axis.values) {
280
+ if (resolved.selection.context[axis.id] === value.id) continue;
281
+ entries.push(
282
+ discoveryEntry(
283
+ "context",
284
+ `${axis.id}=${value.id}`,
285
+ `${axis.name}: ${value.name}`,
286
+ localized.context,
287
+ {
288
+ ...selectionArguments(resolved.selection),
289
+ context: Object.entries({
290
+ ...resolved.selection.context,
291
+ [axis.id]: value.id,
292
+ })
293
+ .sort(([left], [right]) => left.localeCompare(right))
294
+ .map(([contextAxis, contextValue]) =>
295
+ `${contextAxis}=${contextValue}`,
296
+ ),
297
+ },
298
+ {
299
+ defaults: { [axis.id]: axis.defaultValue },
300
+ validValues: axis.values.map(({ id }) => id),
301
+ },
302
+ ),
303
+ );
304
+ }
305
+ }
306
+ for (const profile of [...workspaceProfiles(product, options.foundation).values()].sort(
307
+ (left, right) => left.id.localeCompare(right.id),
308
+ )) {
309
+ entries.push(
310
+ discoveryEntry(
311
+ "context",
312
+ profile.id,
313
+ profile.name,
314
+ localized.context,
315
+ {
316
+ ...selectionArguments(resolved.selection, { context: false }),
317
+ "context-profile": profile.id,
318
+ },
319
+ { defaults: {}, validValues: [profile.id] },
320
+ ),
321
+ );
322
+ }
323
+ for (const scenario of [...product.domain.scenarios.values()]
324
+ .filter(
325
+ (candidate) =>
326
+ candidate.target.kind === "screen" &&
327
+ candidate.target.screen.assetId === resolved.screen?.id,
328
+ )
329
+ .sort((left, right) => left.id.localeCompare(right.id))) {
330
+ if (scenario.id === resolved.selection.scenarioId) continue;
331
+ entries.push(
332
+ discoveryEntry(
333
+ "scenario",
334
+ scenario.id,
335
+ scenario.name,
336
+ localized.scenario,
337
+ {
338
+ ...selectionArguments(resolved.selection, { scenario: false }),
339
+ presentation:
340
+ scenario.target.presentationId ?? resolved.selection.presentationId,
341
+ scenario: scenario.id,
342
+ screen: scenario.target.screen.assetId,
343
+ },
344
+ {
345
+ defaults: { scenario: null },
346
+ validValues: [...product.domain.scenarios.keys()].sort(),
347
+ },
348
+ ),
349
+ );
350
+ }
351
+ for (const viewFormat of VIEW_FORMATS) {
352
+ if (viewFormat === resolved.selection.viewFormat) continue;
353
+ entries.push(
354
+ discoveryEntry(
355
+ "view-format",
356
+ viewFormat,
357
+ viewFormat,
358
+ localized["view-format"],
359
+ { ...selectionArguments(resolved.selection), format: viewFormat },
360
+ { defaults: { format: "structure" }, validValues: VIEW_FORMATS },
361
+ ),
362
+ );
363
+ }
364
+ const offset = Math.max(0, options.offset ?? 0);
365
+ const limit = Math.max(1, Math.min(100, options.limit ?? 20));
366
+ return {
367
+ entries: entries.slice(offset, offset + limit),
368
+ list: {
369
+ complete: offset === 0 && offset + limit >= entries.length,
370
+ kind: "finite",
371
+ total: entries.length,
372
+ },
373
+ page: {
374
+ hasMore: offset + limit < entries.length,
375
+ limit,
376
+ offset,
377
+ total: entries.length,
378
+ },
379
+ selection: structuredClone(resolved.selection),
380
+ };
381
+ }
382
+
383
+ export function projectDesignView(product, resolved, options = {}) {
384
+ return resolved.scenarioId
385
+ ? projectScenario(product, resolved.scenarioId, {
386
+ context: resolved.selection.context,
387
+ foundation: options.foundation,
388
+ libraries: options.libraries,
389
+ })
390
+ : projectScreen(product, resolved.screen.id, {
391
+ context: resolved.selection.context,
392
+ foundation: options.foundation,
393
+ libraries: options.libraries,
394
+ presentationId: resolved.presentation.id,
395
+ });
396
+ }
397
+
398
+ function multiplyMatrix(left, right) {
399
+ return [
400
+ left[0] * right[0] + left[2] * right[1],
401
+ left[1] * right[0] + left[3] * right[1],
402
+ left[0] * right[2] + left[2] * right[3],
403
+ left[1] * right[2] + left[3] * right[3],
404
+ left[0] * right[4] + left[2] * right[5] + left[4],
405
+ left[1] * right[4] + left[3] * right[5] + left[5],
406
+ ];
407
+ }
408
+
409
+ function translationMatrix(x, y) {
410
+ return [1, 0, 0, 1, x, y];
411
+ }
412
+
413
+ function semanticNodeMatrix(node) {
414
+ const angle = ((node.rotation ?? 0) * Math.PI) / 180;
415
+ const horizontal = node.flipX ? -1 : 1;
416
+ const vertical = node.flipY ? -1 : 1;
417
+ const transform = [
418
+ Math.cos(angle) * horizontal,
419
+ Math.sin(angle) * horizontal,
420
+ -Math.sin(angle) * vertical,
421
+ Math.cos(angle) * vertical,
422
+ 0,
423
+ 0,
424
+ ];
425
+ return multiplyMatrix(
426
+ translationMatrix(node.x, node.y),
427
+ multiplyMatrix(
428
+ translationMatrix(node.width / 2, node.height / 2),
429
+ multiplyMatrix(
430
+ transform,
431
+ translationMatrix(-node.width / 2, -node.height / 2),
432
+ ),
433
+ ),
434
+ );
435
+ }
436
+
437
+ function matrixPoint(matrix, x, y) {
438
+ return {
439
+ x: matrix[0] * x + matrix[2] * y + matrix[4],
440
+ y: matrix[1] * x + matrix[3] * y + matrix[5],
441
+ };
442
+ }
443
+
444
+ function semanticBounds(matrix, node) {
445
+ const corners = [
446
+ matrixPoint(matrix, 0, 0),
447
+ matrixPoint(matrix, node.width, 0),
448
+ matrixPoint(matrix, 0, node.height),
449
+ matrixPoint(matrix, node.width, node.height),
450
+ ];
451
+ const left = Math.min(...corners.map(({ x }) => x));
452
+ const right = Math.max(...corners.map(({ x }) => x));
453
+ const top = Math.min(...corners.map(({ y }) => y));
454
+ const bottom = Math.max(...corners.map(({ y }) => y));
455
+ return { height: bottom - top, width: right - left, x: left, y: top };
456
+ }
457
+
458
+ function semanticNode(nodes, nodeId, parentMatrix, ancestors) {
459
+ if (ancestors.has(nodeId)) fail("node_cycle", `Projection cycle includes ${nodeId}`);
460
+ const node = nodes[nodeId];
461
+ if (!node) fail("missing_node", `Projected Node is missing: ${nodeId}`);
462
+ const matrix = multiplyMatrix(parentMatrix, semanticNodeMatrix(node));
463
+ const bounds = semanticBounds(matrix, node);
464
+ const next = new Set(ancestors);
465
+ next.add(nodeId);
466
+ return {
467
+ bounds,
468
+ children: (node.children ?? []).map((childId) =>
469
+ semanticNode(nodes, childId, matrix, next),
470
+ ),
471
+ ...(node.componentRef ? { component: structuredClone(node.componentRef) } : {}),
472
+ constraints: {
473
+ horizontal: node.horizontalConstraint ?? "MIN",
474
+ vertical: node.verticalConstraint ?? "MIN",
475
+ },
476
+ id: node.id,
477
+ name: node.name,
478
+ tokenBindings: structuredClone(node.tokenBindings ?? {}),
479
+ ...(typeof node.text === "string" ? { text: node.text } : {}),
480
+ ...(node.textStyle ? { textStyle: structuredClone(node.textStyle) } : {}),
481
+ type: node.type,
482
+ ...(node.variantSelection
483
+ ? { variant: structuredClone(node.variantSelection) }
484
+ : {}),
485
+ visible: node.visible !== false,
486
+ };
487
+ }
488
+
489
+ export function createSemanticTree(product, projection, options = {}) {
490
+ const interactions = projection.presentation?.interactions ?? [];
491
+ return {
492
+ ...(options.foundationRevision
493
+ ? { foundationRevision: options.foundationRevision }
494
+ : {}),
495
+ ...(interactions.length > 0
496
+ ? { interactions: structuredClone(interactions) }
497
+ : {}),
498
+ packageId: product.manifest.packageId,
499
+ productRevision: product.revision,
500
+ revision: product.revision,
501
+ root: semanticNode(
502
+ projection.nodes,
503
+ projection.rootId,
504
+ [1, 0, 0, 1, 0, 0],
505
+ new Set(),
506
+ ),
507
+ ...(options.scenarioId ? { scenarioId: options.scenarioId } : {}),
508
+ selection: options.selection ? structuredClone(options.selection) : undefined,
509
+ };
510
+ }
511
+
512
+ const WIREFRAME_COLUMNS = 72;
513
+ const WIREFRAME_MIN_ROWS = 18;
514
+ const WIREFRAME_MAX_ROWS = 36;
515
+
516
+ function wireframeNumber(value) {
517
+ const rounded = Math.round(value * 100) / 100;
518
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2);
519
+ }
520
+
521
+ function wireframeBounds(bounds) {
522
+ const x = Number.isFinite(bounds?.x) ? bounds.x : 0;
523
+ const y = Number.isFinite(bounds?.y) ? bounds.y : 0;
524
+ const width = Number.isFinite(bounds?.width) ? Math.max(0, bounds.width) : 0;
525
+ const height = Number.isFinite(bounds?.height) ? Math.max(0, bounds.height) : 0;
526
+ return { height, width, x, y };
527
+ }
528
+
529
+ function wireframeLayers(root) {
530
+ const result = [];
531
+ const visit = (node, depth, parentIndex) => {
532
+ const index = result.length + 1;
533
+ result.push({ depth, index, node, parentIndex });
534
+ for (const child of node.children ?? []) visit(child, depth + 1, index);
535
+ };
536
+ visit(root, 0, undefined);
537
+ const digits = Math.max(2, String(result.length).length);
538
+ return result.map((layer) => ({
539
+ ...layer,
540
+ marker: `${layer.node.visible === false ? "(" : "["}${String(layer.index).padStart(digits, "0")}${layer.node.visible === false ? ")" : "]"}`,
541
+ }));
542
+ }
543
+
544
+ function wireframeGrid(rootBounds) {
545
+ const aspect = rootBounds.width > 0 && rootBounds.height > 0
546
+ ? rootBounds.width / rootBounds.height
547
+ : 1;
548
+ const rows = Math.max(
549
+ WIREFRAME_MIN_ROWS,
550
+ Math.min(
551
+ WIREFRAME_MAX_ROWS,
552
+ Math.round(WIREFRAME_COLUMNS / aspect / 2),
553
+ ),
554
+ );
555
+ return Array.from({ length: rows }, () =>
556
+ Array.from({ length: WIREFRAME_COLUMNS }, () => " "),
557
+ );
558
+ }
559
+
560
+ function wireframeCellBounds(bounds, viewport, columns, rows) {
561
+ const horizontal = (value) =>
562
+ Math.max(
563
+ 0,
564
+ Math.min(
565
+ columns - 1,
566
+ Math.round(((value - viewport.x) / (viewport.width || 1)) * (columns - 1)),
567
+ ),
568
+ );
569
+ const vertical = (value) =>
570
+ Math.max(
571
+ 0,
572
+ Math.min(
573
+ rows - 1,
574
+ Math.round(((value - viewport.y) / (viewport.height || 1)) * (rows - 1)),
575
+ ),
576
+ );
577
+ const normalized = wireframeBounds(bounds);
578
+ const left = horizontal(normalized.x);
579
+ const right = horizontal(normalized.x + normalized.width);
580
+ const top = vertical(normalized.y);
581
+ const bottom = vertical(normalized.y + normalized.height);
582
+ return {
583
+ bottom: Math.max(top, bottom),
584
+ left: Math.min(left, right),
585
+ right: Math.max(left, right),
586
+ top: Math.min(top, bottom),
587
+ };
588
+ }
589
+
590
+ function drawWireframeBounds(grid, bounds, hidden) {
591
+ const horizontal = hidden ? "." : "-";
592
+ const vertical = hidden ? ":" : "|";
593
+ const corner = hidden ? "." : "+";
594
+ for (let column = bounds.left; column <= bounds.right; column += 1) {
595
+ grid[bounds.top][column] = horizontal;
596
+ grid[bounds.bottom][column] = horizontal;
597
+ }
598
+ for (let row = bounds.top; row <= bounds.bottom; row += 1) {
599
+ grid[row][bounds.left] = vertical;
600
+ grid[row][bounds.right] = vertical;
601
+ }
602
+ grid[bounds.top][bounds.left] = corner;
603
+ grid[bounds.top][bounds.right] = corner;
604
+ grid[bounds.bottom][bounds.left] = corner;
605
+ grid[bounds.bottom][bounds.right] = corner;
606
+ }
607
+
608
+ function wireframeMarkerPosition(
609
+ grid,
610
+ occupied,
611
+ text,
612
+ bounds,
613
+ includeWholeGrid,
614
+ ) {
615
+ const rows = grid.length;
616
+ const columns = grid[0].length;
617
+ const preferredRow = Math.min(
618
+ bounds.bottom,
619
+ bounds.top + (bounds.bottom - bounds.top >= 2 ? 1 : 0),
620
+ );
621
+ const preferredColumn = Math.min(
622
+ Math.max(0, columns - text.length),
623
+ bounds.left + (bounds.right - bounds.left >= text.length + 1 ? 1 : 0),
624
+ );
625
+ const candidates = [];
626
+ for (let row = bounds.top; row <= bounds.bottom; row += 1) {
627
+ for (
628
+ let column = bounds.left;
629
+ column <= Math.min(bounds.right - text.length + 1, columns - text.length);
630
+ column += 1
631
+ ) {
632
+ candidates.push({
633
+ column,
634
+ distance: Math.abs(row - preferredRow) + Math.abs(column - preferredColumn),
635
+ row,
636
+ });
637
+ }
638
+ }
639
+ if (includeWholeGrid) {
640
+ for (let row = 0; row < rows; row += 1) {
641
+ for (let column = 0; column <= columns - text.length; column += 1) {
642
+ candidates.push({
643
+ column,
644
+ distance:
645
+ rows +
646
+ Math.abs(row - preferredRow) +
647
+ Math.abs(column - preferredColumn),
648
+ row,
649
+ });
650
+ }
651
+ }
652
+ }
653
+ candidates.sort(
654
+ (left, right) =>
655
+ left.distance - right.distance ||
656
+ left.row - right.row ||
657
+ left.column - right.column,
658
+ );
659
+ return candidates.find(({ column, row }) =>
660
+ text
661
+ .split("")
662
+ .every((_, offset) => occupied[row][column + offset] === false),
663
+ );
664
+ }
665
+
666
+ function writeWireframeText(grid, occupied, text, position) {
667
+ text.split("").forEach((character, offset) => {
668
+ grid[position.row][position.column + offset] = character;
669
+ occupied[position.row][position.column + offset] = true;
670
+ });
671
+ }
672
+
673
+ function placeWireframeMarker(grid, occupied, layer, bounds) {
674
+ const marker = layer.marker;
675
+ const position = wireframeMarkerPosition(
676
+ grid,
677
+ occupied,
678
+ marker,
679
+ bounds,
680
+ true,
681
+ );
682
+ if (!position) return;
683
+ writeWireframeText(grid, occupied, marker, position);
684
+ }
685
+
686
+ function wireframeLayerLine(layer) {
687
+ const node = layer.node;
688
+ const hidden = node.visible === false ? "HIDDEN " : "";
689
+ return `${" ".repeat(layer.depth)}${layer.marker} ${hidden}${node.type} ${JSON.stringify(node.name)} #${node.id}`;
690
+ }
691
+
692
+ export function asciiWireframe(tree) {
693
+ const viewport = wireframeBounds(tree.root.bounds);
694
+ const layers = wireframeLayers(tree.root);
695
+ const grid = wireframeGrid(viewport);
696
+ const occupied = grid.map((row) => row.map(() => false));
697
+ const cellBounds = new Map();
698
+ for (const layer of layers) {
699
+ const bounds = wireframeCellBounds(
700
+ layer.node.bounds,
701
+ viewport,
702
+ grid[0].length,
703
+ grid.length,
704
+ );
705
+ cellBounds.set(layer.index, bounds);
706
+ drawWireframeBounds(grid, bounds, layer.node.visible === false);
707
+ }
708
+ for (const layer of layers) {
709
+ placeWireframeMarker(
710
+ grid,
711
+ occupied,
712
+ layer,
713
+ cellBounds.get(layer.index),
714
+ );
715
+ }
716
+ const canvas = grid.map((row) => row.join("")).join("\n");
717
+ const layerIndex = layers
718
+ .map((layer) => wireframeLayerLine(layer))
719
+ .join("\n");
720
+ return [
721
+ "ASCII WIREFRAME",
722
+ `viewport: ${wireframeNumber(viewport.width)}x${wireframeNumber(viewport.height)} @ ${wireframeNumber(viewport.x)},${wireframeNumber(viewport.y)} | canvas: ${grid[0].length}x${grid.length} chars`,
723
+ "markers: [NN]=visible (NN)=hidden; geometry is approximate; exact data is in semanticTree",
724
+ "CANVAS",
725
+ canvas,
726
+ "LAYER KEY (back-to-front; indentation shows containment)",
727
+ layerIndex,
728
+ "",
729
+ ].join("\n");
730
+ }
731
+
732
+ function semanticValues(value, path, result) {
733
+ if (Array.isArray(value)) {
734
+ value.forEach((child, index) =>
735
+ semanticValues(child, `${path}[${index}]`, result),
736
+ );
737
+ } else if (value && typeof value === "object") {
738
+ for (const [name, child] of Object.entries(value).sort(([left], [right]) =>
739
+ left.localeCompare(right),
740
+ )) {
741
+ if (
742
+ name === "revision" ||
743
+ name === "productRevision" ||
744
+ name === "foundationRevision"
745
+ ) continue;
746
+ semanticValues(child, path ? `${path}.${name}` : name, result);
747
+ }
748
+ } else {
749
+ result.set(path, JSON.stringify(value));
750
+ }
751
+ }
752
+
753
+ export function diffSemanticTrees(before, after) {
754
+ const beforeValues = new Map();
755
+ const afterValues = new Map();
756
+ semanticValues(before, "", beforeValues);
757
+ semanticValues(after, "", afterValues);
758
+ return [...new Set([...beforeValues.keys(), ...afterValues.keys()])]
759
+ .sort()
760
+ .flatMap((path) => {
761
+ const beforeValue = beforeValues.get(path);
762
+ const afterValue = afterValues.get(path);
763
+ if (beforeValue === afterValue) return [];
764
+ if (beforeValue === undefined) return [{ after: afterValue, kind: "added", path }];
765
+ if (afterValue === undefined) return [{ before: beforeValue, kind: "removed", path }];
766
+ return [{ after: afterValue, before: beforeValue, kind: "changed", path }];
767
+ });
768
+ }
769
+
770
+ export function readDesignView(product, options = {}) {
771
+ const resolved = resolveDesignView(product, options);
772
+ const projection = projectDesignView(product, resolved, options);
773
+ const semantic = createSemanticTree(product, projection, {
774
+ foundationRevision: options.foundation?.revision,
775
+ scenarioId: resolved.scenarioId,
776
+ selection: resolved.selection,
777
+ });
778
+ const formats = {
779
+ screenshot: { projection, status: "render_required" },
780
+ semantic,
781
+ structure: projection,
782
+ wireframe: asciiWireframe(semantic),
783
+ };
784
+ return {
785
+ discovery: createDiscoveryGuide(product, resolved, {
786
+ foundation: options.foundation,
787
+ limit: options.limit,
788
+ locale: options.locale,
789
+ offset: options.offset,
790
+ }),
791
+ format: resolved.selection.viewFormat,
792
+ ...(options.foundation
793
+ ? { foundationRevision: options.foundation.revision }
794
+ : {}),
795
+ productRevision: product.revision,
796
+ revision: product.revision,
797
+ result: formats[resolved.selection.viewFormat],
798
+ selection: resolved.selection,
799
+ };
800
+ }
801
+
802
+ export function createCompareView(product, selectors, options = {}) {
803
+ if (!Array.isArray(selectors) || selectors.length < 2 || selectors.length > 4) {
804
+ fail(
805
+ "invalid_compare_count",
806
+ "Compare requires two to four explicitly selected Design Views",
807
+ {
808
+ next: {
809
+ args: { maximum: 4, minimum: 2 },
810
+ operation: "smallpen.compare.select",
811
+ },
812
+ path: "selectors",
813
+ },
814
+ );
815
+ }
816
+ return {
817
+ items: selectors.map((selector) => {
818
+ const resolved = resolveDesignView(product, { ...options, selector });
819
+ return {
820
+ projection: projectDesignView(product, resolved, options),
821
+ selection: resolved.selection,
822
+ };
823
+ }),
824
+ };
825
+ }