@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,437 @@
1
+ // DSP-003: Workbench Combination enumeration, pure preview evaluation and
2
+ // explicit edit-target resolution for the design system workbench.
3
+ //
4
+ // Terminology (CONTEXT.md): a Token Domain groups Token Variants; a Paired
5
+ // Theme activates one Variant's Token Set. The Workbench Combination is the
6
+ // *observed* selection and never mutates the project's Current Combination.
7
+ import { listEffectiveTokens } from "./effective-tokens.mjs";
8
+ import { tokenInventoryRows } from "./catalog.mjs";
9
+ import { loadPackageFromValues } from "./package.mjs";
10
+
11
+ function compareText(left, right) {
12
+ return left < right ? -1 : left > right ? 1 : 0;
13
+ }
14
+
15
+ function tokenLibraryEntries(snapshot) {
16
+ return snapshot.manifest.entries.tokens.map((entry) => ({
17
+ entry,
18
+ library: snapshot.entries[entry],
19
+ }));
20
+ }
21
+
22
+ function aliasPathOf(value) {
23
+ if (typeof value !== "string") return null;
24
+ return /^\{([^{}]+)\}$/.exec(value)?.[1] ?? null;
25
+ }
26
+
27
+ // DSP-003-A: enumerate the Workbench Combinations offered to the user, from
28
+ // the Paired Themes already defined in the package. No names are hardcoded;
29
+ // selection is by stable theme ids. The project Current Combination is
30
+ // reported per domain so the UI can show what the workbench does NOT change.
31
+ export function enumerateWorkbenchCombinations(snapshot) {
32
+ const domains = new Map();
33
+ const activeThemeIds = new Set();
34
+ const libraries = tokenLibraryEntries(snapshot);
35
+ for (const { library } of libraries) {
36
+ for (const themeId of library.activeThemeIds ?? []) {
37
+ activeThemeIds.add(themeId);
38
+ }
39
+ }
40
+ for (const { library } of libraries) {
41
+ for (const theme of library.themes ?? []) {
42
+ const domain = theme.group;
43
+ if (!domains.has(domain)) {
44
+ domains.set(domain, {
45
+ currentProjectThemeId: null,
46
+ domain,
47
+ domainId: `domain_${domain}`,
48
+ variants: [],
49
+ });
50
+ }
51
+ const record = domains.get(domain);
52
+ if (activeThemeIds.has(theme.id)) {
53
+ record.currentProjectThemeId = theme.id;
54
+ }
55
+ record.variants.push({
56
+ name: theme.name,
57
+ setIds: [...(theme.setIds ?? [])],
58
+ themeId: theme.id,
59
+ });
60
+ }
61
+ }
62
+ const diagnostics = [];
63
+ const knownThemeIds = new Set(
64
+ libraries.flatMap(({ library }) =>
65
+ (library.themes ?? []).map((theme) => theme.id),
66
+ ),
67
+ );
68
+ for (const themeId of activeThemeIds) {
69
+ if (!knownThemeIds.has(themeId)) {
70
+ diagnostics.push({
71
+ code: "combination_unknown_active_theme",
72
+ message: `Active theme id is not defined in any token library: ${themeId}`,
73
+ themeId,
74
+ });
75
+ }
76
+ }
77
+ const domainsList = [...domains.values()].sort((left, right) =>
78
+ compareText(left.domain, right.domain),
79
+ );
80
+ for (const domain of domainsList) {
81
+ if (!domain.currentProjectThemeId) {
82
+ diagnostics.push({
83
+ code: "combination_domain_unselected",
84
+ domain: domain.domain,
85
+ domainId: domain.domainId,
86
+ message: `Token Domain has no active Paired Theme: ${domain.domain}`,
87
+ });
88
+ }
89
+ }
90
+ return { diagnostics, domains: domainsList };
91
+ }
92
+
93
+ // DSP-003-A: validate a workbench selection given as stable-id pairs
94
+ // [{domainId, themeId}]. Empty diagnostics means the combination is usable.
95
+ export function validateWorkbenchCombination(snapshot, combination) {
96
+ const { diagnostics: enumerationDiagnostics, domains } =
97
+ enumerateWorkbenchCombinations(snapshot);
98
+ const diagnostics = [...enumerationDiagnostics];
99
+ const byId = new Map(domains.map((domain) => [domain.domainId, domain]));
100
+ const seen = new Set();
101
+ for (const selection of combination ?? []) {
102
+ const domain = byId.get(selection?.domainId);
103
+ if (!domain) {
104
+ diagnostics.push({
105
+ code: "combination_unknown_domain",
106
+ domainId: selection?.domainId ?? null,
107
+ message: `Unknown Token Domain: ${String(selection?.domainId)}`,
108
+ });
109
+ continue;
110
+ }
111
+ if (seen.has(domain.domainId)) {
112
+ diagnostics.push({
113
+ code: "combination_duplicate_domain",
114
+ domainId: domain.domainId,
115
+ message: `Domain selected more than once: ${domain.domain}`,
116
+ });
117
+ continue;
118
+ }
119
+ seen.add(domain.domainId);
120
+ const variant = domain.variants.find(
121
+ (candidate) => candidate.themeId === selection?.themeId,
122
+ );
123
+ if (!variant) {
124
+ diagnostics.push({
125
+ code: "combination_unknown_variant",
126
+ domainId: domain.domainId,
127
+ message: `Theme is not a variant of Token Domain ${domain.domain}: ${String(selection?.themeId)}`,
128
+ themeId: selection?.themeId ?? null,
129
+ });
130
+ }
131
+ }
132
+ for (const domain of domains) {
133
+ if (!seen.has(domain.domainId) && domain.variants.length > 0) {
134
+ diagnostics.push({
135
+ code: "combination_missing_domain",
136
+ domain: domain.domain,
137
+ domainId: domain.domainId,
138
+ message: `Workbench Combination must select Token Domain: ${domain.domain}`,
139
+ });
140
+ }
141
+ }
142
+ return { diagnostics };
143
+ }
144
+
145
+ function clonePackageValues(snapshot) {
146
+ const values = new Map();
147
+ values.set("manifest.json", structuredClone(snapshot.manifest));
148
+ for (const [entry, value] of Object.entries(snapshot.entries)) {
149
+ values.set(entry, structuredClone(value));
150
+ }
151
+ return values;
152
+ }
153
+
154
+ function homeSetOf(previewSnapshot, token) {
155
+ const filePath = token.filePath;
156
+ const library = previewSnapshot.entries[filePath];
157
+ if (!library || !Array.isArray(library.sets)) return null;
158
+ for (const set of library.sets) {
159
+ if ((set.tokens ?? []).some((candidate) => candidate.id === token.id)) {
160
+ return { setId: set.id, setName: set.name };
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+
166
+ function rawValueOf(previewSnapshot, token) {
167
+ const library = previewSnapshot.entries[token.filePath];
168
+ for (const set of library?.sets ?? []) {
169
+ for (const candidate of set.tokens ?? []) {
170
+ if (candidate.id === token.id) return candidate.value;
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ // Raw/alias/resolved/source rows over an already-built preview snapshot.
177
+ function previewTokenRows(previewSnapshot) {
178
+ return listEffectiveTokens(previewSnapshot).map((effective) => {
179
+ const token = effective.token;
180
+ const home = homeSetOf(previewSnapshot, token) ?? {
181
+ setId: null,
182
+ setName: null,
183
+ };
184
+ const raw = rawValueOf(previewSnapshot, token);
185
+ return {
186
+ alias: aliasPathOf(raw),
187
+ homeSetId: home.setId,
188
+ homeSetName: home.setName,
189
+ ownerPackageId: previewSnapshot.manifest.packageId,
190
+ path: token.path,
191
+ raw,
192
+ resolved: effective.value,
193
+ setId: home.setId,
194
+ setName: home.setName,
195
+ sourceTokenId: token.id,
196
+ type: token.type,
197
+ };
198
+ });
199
+ }
200
+
201
+ // DSP-003-B: pure evaluation preview. Patches the *observed* active themes in
202
+ // a cloned package (never the source, never an activate event) and runs the
203
+ // authoritative token resolver over it.
204
+ export async function createWorkbenchPreview(snapshot, combination) {
205
+ const { diagnostics } = validateWorkbenchCombination(snapshot, combination);
206
+ if (diagnostics.length > 0) {
207
+ const error = new Error(
208
+ `Invalid workbench combination: ${diagnostics
209
+ .map((entry) => entry.code)
210
+ .join(", ")}`,
211
+ );
212
+ error.code = "invalid_workbench_combination";
213
+ error.diagnostics = diagnostics;
214
+ throw error;
215
+ }
216
+ const enumeration = enumerateWorkbenchCombinations(snapshot);
217
+ const values = clonePackageValues(snapshot);
218
+ const observedThemeByGroup = new Map(
219
+ (combination ?? []).map((selection) => {
220
+ const domain = enumeration.domains.find(
221
+ (candidate) => candidate.domainId === selection.domainId,
222
+ );
223
+ return [domain.domain, selection.themeId];
224
+ }),
225
+ );
226
+ for (const libraryEntry of values.values()) {
227
+ if (!libraryEntry || !Array.isArray(libraryEntry.themes)) continue;
228
+ libraryEntry.activeThemeIds = libraryEntry.themes
229
+ .filter(
230
+ (theme) => observedThemeByGroup.get(theme.group) === theme.id,
231
+ )
232
+ .map((theme) => theme.id);
233
+ }
234
+ const preview = await loadPackageFromValues(snapshot.locator, values);
235
+ return {
236
+ combination: (combination ?? []).map((selection) => ({ ...selection })),
237
+ preview,
238
+ tokens: previewTokenRows(preview),
239
+ };
240
+ }
241
+
242
+ function editablePackageId(previewSnapshot) {
243
+ return previewSnapshot.manifest.packageId;
244
+ }
245
+
246
+ // DSP-003-C: explicit edit targets for one displayed value. Targets are only
247
+ // offered when unambiguous: the owning cell, the alias expression (when the
248
+ // raw value is an alias), the alias's shared target cell, and a variant
249
+ // override when the observed variant set differs from the cell's home set.
250
+ export function resolveWorkbenchEditTargets(
251
+ previewSnapshot,
252
+ combination,
253
+ token,
254
+ ) {
255
+ const rows = previewTokenRows(previewSnapshot);
256
+ const row = rows.find(
257
+ (candidate) =>
258
+ candidate.path === token.path &&
259
+ candidate.ownerPackageId === token.ownerPackageId,
260
+ );
261
+ if (!row) {
262
+ return {
263
+ diagnostics: [{ code: "token_not_in_preview", path: token.path }],
264
+ targets: [],
265
+ };
266
+ }
267
+ const editablePackage = editablePackageId(previewSnapshot);
268
+ const homeReadOnly = row.ownerPackageId !== editablePackage;
269
+ const targets = [
270
+ {
271
+ kind: "cell",
272
+ ownerPackageId: row.ownerPackageId,
273
+ path: row.path,
274
+ readOnly: homeReadOnly,
275
+ setId: row.setId,
276
+ tokenId: row.sourceTokenId,
277
+ },
278
+ ];
279
+ if (row.alias) {
280
+ targets.push({
281
+ kind: "alias-expression",
282
+ current: `{${row.alias}}`,
283
+ ownerPackageId: row.ownerPackageId,
284
+ path: row.path,
285
+ readOnly: homeReadOnly,
286
+ tokenId: row.sourceTokenId,
287
+ });
288
+ const target = rows.find((candidate) => candidate.path === row.alias);
289
+ if (target) {
290
+ targets.push({
291
+ kind: "shared-cell",
292
+ ownerPackageId: target.ownerPackageId,
293
+ path: target.path,
294
+ readOnly: target.ownerPackageId !== editablePackage,
295
+ setId: target.setId,
296
+ tokenId: target.sourceTokenId,
297
+ });
298
+ }
299
+ }
300
+ const domain = row.homeSetName ? row.homeSetName.split("/")[0] : null;
301
+ const selection = (combination ?? []).find(
302
+ (candidate) => candidate.domainId === `domain_${domain}`,
303
+ );
304
+ if (domain && selection) {
305
+ const observed = findSetByTheme(previewSnapshot, selection.themeId);
306
+ if (observed && observed.id !== row.setId) {
307
+ targets.push({
308
+ kind: "variant-override",
309
+ observedSetId: observed.id,
310
+ observedSetName: observed.name,
311
+ ownerPackageId: row.ownerPackageId,
312
+ path: row.path,
313
+ readOnly: homeReadOnly,
314
+ });
315
+ }
316
+ }
317
+ return { diagnostics: [], targets };
318
+ }
319
+
320
+ function findSetByTheme(previewSnapshot, themeId) {
321
+ for (const entry of Object.values(previewSnapshot.entries)) {
322
+ if (!Array.isArray(entry?.themes)) continue;
323
+ const theme = entry.themes.find((candidate) => candidate.id === themeId);
324
+ if (theme) {
325
+ const set = (entry.sets ?? []).find((candidate) =>
326
+ (theme.setIds ?? []).includes(candidate.id),
327
+ );
328
+ if (set) return set;
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // DSP-004-B: build the generated System Sheet as pure data. Every specimen
335
+ // carries a stable projection identity derived from its qualified source key
336
+ // and points at the real editable target (Token Cell or component variant).
337
+ // Decorative elements (labels, section headers, rulers, scale) are flagged
338
+ // separately and are never design nodes.
339
+ export function buildWorkbenchSheet(snapshot, options = {}) {
340
+ const libraries = options.libraries ?? [];
341
+ const foundation = options.foundation;
342
+ const packageId = snapshot.manifest.packageId;
343
+ const sections = new Map();
344
+ const sources = [
345
+ { rows: tokenInventoryRows(snapshot, "product"), owner: packageId, source: "product" },
346
+ ...(foundation
347
+ ? [{ rows: tokenInventoryRows(foundation, "foundation"), owner: foundation.manifest.packageId, source: "foundation" }]
348
+ : []),
349
+ ...libraries.map((library) => ({
350
+ rows: tokenInventoryRows(library, "library"),
351
+ owner: library.manifest.packageId,
352
+ source: "library",
353
+ })),
354
+ ];
355
+ for (const { rows, owner } of sources) {
356
+ for (const row of rows) {
357
+ const domain = row.setName.split("/")[0];
358
+ const sectionId = `dsp-section-${domain}`;
359
+ if (!sections.has(sectionId)) {
360
+ sections.set(sectionId, {
361
+ id: sectionId,
362
+ kind: "section",
363
+ name: domain,
364
+ // The section header/ruler are generated decorations.
365
+ decoration: true,
366
+ specimens: [],
367
+ });
368
+ }
369
+ sections.get(sectionId).specimens.push({
370
+ specimenId: `dsp-specimen/${row.qualifiedKey}`,
371
+ kind: "token",
372
+ decoration: false,
373
+ active: row.active,
374
+ readOnly: owner !== packageId,
375
+ target: {
376
+ kind: "token-cell",
377
+ ownerPackageId: row.ownerPackageId,
378
+ qualifiedKey: row.qualifiedKey,
379
+ setId: row.setId,
380
+ setName: row.setName,
381
+ tokenId: row.tokenId ?? row.path,
382
+ path: row.path,
383
+ },
384
+ display: { type: row.type, value: row.value },
385
+ });
386
+ }
387
+ }
388
+ // Component specimens: one per (component set, variant).
389
+ const componentSetsOf = (snapshot, source, owner) =>
390
+ [...snapshot.manifest.entries.components]
391
+ .map((entry) => snapshot.entries[entry])
392
+ .filter((componentFile) => Array.isArray(componentFile.componentSets))
393
+ .flatMap((componentFile) => componentFile.componentSets)
394
+ .filter((componentSet) => !publicFilter(componentSet))
395
+ .flatMap((componentSet) =>
396
+ componentSet.variants.map((variant) => ({
397
+ specimenId: `dsp-specimen/component/${owner}/${componentSet.id}/${variant.id}`,
398
+ kind: "component",
399
+ decoration: false,
400
+ readOnly: source !== "product",
401
+ target: {
402
+ kind: "component-definition",
403
+ componentSetId: componentSet.id,
404
+ ownerPackageId: owner,
405
+ variantId: variant.id,
406
+ rootId: variant.rootId,
407
+ },
408
+ display: { name: componentSet.name, selection: variant.selection },
409
+ })),
410
+ );
411
+ function publicFilter(componentSet) {
412
+ return componentSet.deprecated === true;
413
+ }
414
+ for (const specimen of componentSetsOf(snapshot, "product", packageId)) {
415
+ const sectionId = "dsp-section-components";
416
+ if (!sections.has(sectionId)) {
417
+ sections.set(sectionId, {
418
+ id: sectionId,
419
+ kind: "section",
420
+ name: "Components",
421
+ decoration: true,
422
+ specimens: [],
423
+ });
424
+ }
425
+ sections.get(sectionId).specimens.push(specimen);
426
+ }
427
+ const orderedSections = [...sections.values()].sort((left, right) =>
428
+ compareText(left.id, right.id),
429
+ );
430
+ return {
431
+ id: "dsp-system-sheet",
432
+ kind: "system-sheet",
433
+ // Regenerable: the sheet is derived data and never persisted as pages.
434
+ regenerable: true,
435
+ sections: orderedSections,
436
+ };
437
+ }