@penpair/marwes-ui 0.2.0 → 0.3.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.
package/dist/penpot.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { assertSafeDestruction } from "./ownership.js";
2
- import { waitForStableLayout, guardRevision, writeCoordinator, runSnippet, snippet, guardFile, observationHash, readJournal, readJournalTarget, runJournaledSteps } from "@penpair/core";
3
- import { canonical, normalizeValue } from "./tokens.js";
2
+ import { waitForStableLayout, guardRevision, writeCoordinator, runSnippet, snippet, guardFile, observationHash, readJournal, readJournalTarget, runJournaledSteps, } from "@penpair/core";
3
+ import { canonical, normalizeValue, } from "./tokens.js";
4
4
  import { actionSatisfied, reconcileAction, remainingAction } from "./reconciliation.js";
5
5
  export function readTokens() {
6
6
  return snippet("marwes-tokens-read", `const tokens = penpot.library.local.tokens;
@@ -34,50 +34,120 @@ export function compareToPenpot(expected, live, options = {}) {
34
34
  for (const wanted of expected.sets) {
35
35
  const found = live.sets.find((s) => s.name === wanted.name);
36
36
  if (!found) {
37
- sets.push({ set: wanted.name, missing: true, tokens: wanted.tokens.map((t) => ({ name: t.name, kind: "missing" })) });
37
+ sets.push({
38
+ set: wanted.name,
39
+ missing: true,
40
+ tokens: wanted.tokens.map((t) => ({
41
+ name: t.name,
42
+ kind: "missing",
43
+ })),
44
+ });
38
45
  continue;
39
46
  }
40
47
  const tokens = [];
41
- const have = new Map(found.tokens.map((t) => [t.name, t]));
42
- const want = new Map(wanted.tokens.map((t) => [t.name, t]));
48
+ const have = new Map(found.tokens.map((t) => [
49
+ t.name,
50
+ t,
51
+ ]));
52
+ const want = new Map(wanted.tokens.map((t) => [
53
+ t.name,
54
+ t,
55
+ ]));
43
56
  for (const token of wanted.tokens) {
44
57
  const actual = have.get(token.name);
45
- if (!actual)
46
- tokens.push({ name: token.name, kind: "missing" });
47
- else if (actual.type !== token.type)
48
- tokens.push({ name: token.name, kind: "type", expected: token.type, actual: actual.type });
49
- else if (normalizeValue(token.type, token.value) !== normalizeValue(actual.type, actual.value))
50
- tokens.push({ name: token.name, kind: "value", expected: display(token.type, token.value), actual: display(actual.type, actual.value) });
51
- else if (token.description !== actual.description)
52
- tokens.push({ name: token.name, kind: "description", expected: token.description, actual: actual.description });
53
- }
54
- for (const name of have.keys())
55
- if (!want.has(name))
56
- tokens.push({ name, kind: "extra" });
57
- if (tokens.length > 0)
58
- sets.push({ set: wanted.name, tokens });
58
+ if (!actual) {
59
+ tokens.push({
60
+ name: token.name,
61
+ kind: "missing",
62
+ });
63
+ }
64
+ else if (actual.type !== token.type) {
65
+ tokens.push({
66
+ name: token.name,
67
+ kind: "type",
68
+ expected: token.type,
69
+ actual: actual.type,
70
+ });
71
+ }
72
+ else if (normalizeValue(token.type, token.value) !== normalizeValue(actual.type, actual.value)) {
73
+ tokens.push({
74
+ name: token.name,
75
+ kind: "value",
76
+ expected: display(token.type, token.value),
77
+ actual: display(actual.type, actual.value),
78
+ });
79
+ }
80
+ else if (token.description !== actual.description) {
81
+ tokens.push({
82
+ name: token.name,
83
+ kind: "description",
84
+ expected: token.description,
85
+ actual: actual.description,
86
+ });
87
+ }
88
+ }
89
+ for (const name of have.keys()) {
90
+ if (!want.has(name)) {
91
+ tokens.push({
92
+ name,
93
+ kind: "extra",
94
+ });
95
+ }
96
+ }
97
+ if (tokens.length > 0) {
98
+ sets.push({
99
+ set: wanted.name,
100
+ tokens,
101
+ });
102
+ }
59
103
  }
60
104
  const themes = [];
61
105
  for (const wanted of expected.themes) {
62
106
  const found = live.themes.find((t) => t.group === wanted.group && t.name === wanted.name);
63
107
  if (!found) {
64
- themes.push({ theme: `${wanted.group}/${wanted.name}`, missing: true, missingSets: wanted.sets, extraSets: [] });
108
+ themes.push({
109
+ theme: `${wanted.group}/${wanted.name}`,
110
+ missing: true,
111
+ missingSets: wanted.sets,
112
+ extraSets: [],
113
+ });
65
114
  continue;
66
115
  }
67
116
  const missingSets = wanted.sets.filter((s) => !found.sets.includes(s));
68
- const owned = new Set([...expected.sets, ...(options.ancestor?.sets ?? [])].map(set => set.name));
117
+ const owned = new Set([
118
+ ...expected.sets,
119
+ ...(options.ancestor?.sets ?? []),
120
+ ].map((set) => set.name));
69
121
  const extraSets = found.sets.filter((s) => !wanted.sets.includes(s) && owned.has(s));
70
- if (missingSets.length || extraSets.length)
71
- themes.push({ theme: `${wanted.group}/${wanted.name}`, missingSets, extraSets });
122
+ if (missingSets.length || extraSets.length) {
123
+ themes.push({
124
+ theme: `${wanted.group}/${wanted.name}`,
125
+ missingSets,
126
+ extraSets,
127
+ });
128
+ }
72
129
  }
73
130
  const managed = new Set(expected.sets.map((s) => s.name));
74
- const unexpectedSets = live.sets.map(set => set.name).filter(name => options.ancestor?.sets.some(set => set.name === name) && !managed.has(name));
75
- const actionable = sets.some(set => set.missing || set.tokens.some(token => token.kind !== "extra" || options.ancestor?.sets.find(old => old.name === set.set)?.tokens.some(old => old.name === token.name)));
76
- return { inSync: !actionable && themes.length === 0, sets, themes, unexpectedSets };
131
+ const unexpectedSets = live.sets
132
+ .map((set) => set.name)
133
+ .filter((name) => options.ancestor?.sets.some((set) => set.name === name) && !managed.has(name));
134
+ const actionable = sets.some((set) => set.missing ||
135
+ set.tokens.some((token) => token.kind !== "extra" ||
136
+ options.ancestor?.sets
137
+ .find((old) => old.name === set.set)
138
+ ?.tokens.some((old) => old.name === token.name)));
139
+ return {
140
+ inSync: !actionable && themes.length === 0,
141
+ sets,
142
+ themes,
143
+ unexpectedSets,
144
+ };
77
145
  }
78
146
  export function formatDrift(drift, impact, limit = 6) {
79
147
  const lines = [];
80
- const used = (name) => (impact && impact[name] ? ` (used by ${impact[name]} shape${impact[name] === 1 ? "" : "s"})` : "");
148
+ const used = (name) => impact && impact[name]
149
+ ? ` (used by ${impact[name]} shape${impact[name] === 1 ? "" : "s"})`
150
+ : "";
81
151
  for (const s of drift.sets) {
82
152
  if (s.missing) {
83
153
  lines.push(`${s.set}: missing in Penpot (${s.tokens.length} tokens)`);
@@ -87,70 +157,142 @@ export function formatDrift(drift, impact, limit = 6) {
87
157
  const extra = s.tokens.filter((t) => t.kind === "extra");
88
158
  const changed = s.tokens.filter((t) => t.kind === "value" || t.kind === "type");
89
159
  const described = s.tokens.filter((t) => t.kind === "description");
90
- const cut = (items) => (items.length > limit ? `${items.slice(0, limit).join("; ")}; and ${items.length - limit} more` : items.join("; "));
91
- if (missing.length)
160
+ const cut = (items) => items.length > limit
161
+ ? `${items.slice(0, limit).join("; ")}; and ${items.length - limit} more`
162
+ : items.join("; ");
163
+ if (missing.length) {
92
164
  lines.push(`${s.set}: ${missing.length} missing in Penpot: ${cut(missing.map((t) => t.name))}`);
93
- if (changed.length)
165
+ }
166
+ if (changed.length) {
94
167
  lines.push(`${s.set}: ${changed.length} differ from the expectation: ${cut(changed.map((t) => `${t.name} is ${t.actual}, expected ${t.expected}${used(t.name)}`))}`);
95
- if (extra.length)
168
+ }
169
+ if (extra.length) {
96
170
  lines.push(`${s.set}: ${extra.length} not in the expectation (added by hand?): ${cut(extra.map((t) => t.name))}`);
97
- if (described.length)
171
+ }
172
+ if (described.length) {
98
173
  lines.push(`${s.set}: ${described.length} carry an old description (the recorded Marwes version): ${cut(described.map((t) => t.name))}`);
174
+ }
99
175
  }
100
176
  for (const t of drift.themes) {
101
- if (t.missing)
177
+ if (t.missing) {
102
178
  lines.push(`theme ${t.theme}: missing in Penpot`);
103
- else
179
+ }
180
+ else {
104
181
  lines.push(`theme ${t.theme}: ${t.missingSets.length ? `lacks ${t.missingSets.join(", ")}` : ""}${t.missingSets.length && t.extraSets.length ? "; " : ""}${t.extraSets.length ? `also has ${t.extraSets.join(", ")}` : ""}`);
182
+ }
105
183
  }
106
- for (const name of drift.unexpectedSets)
184
+ for (const name of drift.unexpectedSets) {
107
185
  lines.push(`set ${name}: in Penpot but no longer expected`);
186
+ }
108
187
  return lines;
109
188
  }
110
189
  /** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
111
190
  export function planPush(expected, live, options = {}) {
112
191
  const drift = compareToPenpot(expected, live, options);
113
192
  const actions = [];
114
- const wantedSet = new Map(expected.sets.map((s) => [s.name, s]));
193
+ const wantedSet = new Map(expected.sets.map((s) => [
194
+ s.name,
195
+ s,
196
+ ]));
115
197
  for (const s of drift.sets) {
116
198
  const wanted = wantedSet.get(s.set);
117
199
  if (s.missing) {
118
- actions.push({ kind: "createSet", set: s.set }, { kind: "addTokens", set: s.set, tokens: wanted.tokens });
200
+ actions.push({
201
+ kind: "createSet",
202
+ set: s.set,
203
+ }, {
204
+ kind: "addTokens",
205
+ set: s.set,
206
+ tokens: wanted.tokens,
207
+ });
119
208
  continue;
120
209
  }
121
- const byName = new Map(wanted.tokens.map((t) => [t.name, t]));
210
+ const byName = new Map(wanted.tokens.map((t) => [
211
+ t.name,
212
+ t,
213
+ ]));
122
214
  const add = s.tokens.filter((t) => t.kind === "missing").map((t) => byName.get(t.name));
123
215
  // A typography value assigned in place is stored un-normalised by Penpot, so those are removed and added again.
124
- const typed = s.tokens.filter((t) => t.kind === "type" || ((t.kind === "value" || t.kind === "description") && byName.get(t.name)?.type === "typography")).map((t) => byName.get(t.name));
125
- const replace = [...new Map(typed.map((t) => [t.name, t])).values()];
216
+ const typed = s.tokens
217
+ .filter((t) => t.kind === "type" ||
218
+ ((t.kind === "value" || t.kind === "description") &&
219
+ byName.get(t.name)?.type === "typography"))
220
+ .map((t) => byName.get(t.name));
221
+ const replace = [
222
+ ...new Map(typed.map((t) => [
223
+ t.name,
224
+ t,
225
+ ])).values(),
226
+ ];
126
227
  const replaced = new Set(replace.map((t) => t.name));
127
- const update = s.tokens.filter((t) => (t.kind === "value" || t.kind === "description") && !replaced.has(t.name)).map((t) => byName.get(t.name));
128
- if (add.length)
129
- actions.push({ kind: "addTokens", set: s.set, tokens: add });
130
- if (update.length)
131
- actions.push({ kind: "updateTokens", set: s.set, tokens: update });
132
- if (replace.length)
133
- actions.push({ kind: "replaceTokens", set: s.set, tokens: replace });
228
+ const update = s.tokens
229
+ .filter((t) => (t.kind === "value" || t.kind === "description") && !replaced.has(t.name))
230
+ .map((t) => byName.get(t.name));
231
+ if (add.length) {
232
+ actions.push({
233
+ kind: "addTokens",
234
+ set: s.set,
235
+ tokens: add,
236
+ });
237
+ }
238
+ if (update.length) {
239
+ actions.push({
240
+ kind: "updateTokens",
241
+ set: s.set,
242
+ tokens: update,
243
+ });
244
+ }
245
+ if (replace.length) {
246
+ actions.push({
247
+ kind: "replaceTokens",
248
+ set: s.set,
249
+ tokens: replace,
250
+ });
251
+ }
134
252
  const extra = s.tokens.filter((t) => t.kind === "extra").map((t) => t.name);
135
- const ownedExtra = extra.filter(name => options.ancestor?.sets.find(old => old.name === s.set)?.tokens.some(token => token.name === name));
136
- if (options.prune && ownedExtra.length)
137
- actions.push({ kind: "removeTokens", set: s.set, names: ownedExtra });
253
+ const ownedExtra = extra.filter((name) => options.ancestor?.sets
254
+ .find((old) => old.name === s.set)
255
+ ?.tokens.some((token) => token.name === name));
256
+ if (options.prune && ownedExtra.length) {
257
+ actions.push({
258
+ kind: "removeTokens",
259
+ set: s.set,
260
+ names: ownedExtra,
261
+ });
262
+ }
138
263
  }
139
264
  const createdThemes = new Set();
140
265
  for (const t of drift.themes) {
141
266
  const [group, name] = t.theme.split("/");
142
267
  if (t.missing) {
143
- actions.push({ kind: "createTheme", group, name, sets: expected.themes.find((x) => x.group === group && x.name === name).sets });
268
+ actions.push({
269
+ kind: "createTheme",
270
+ group,
271
+ name,
272
+ sets: expected.themes.find((x) => x.group === group && x.name === name).sets,
273
+ });
144
274
  createdThemes.add(name);
145
275
  }
146
- else
147
- actions.push({ kind: "syncThemeSets", group, name, add: t.missingSets, remove: options.prune ? t.extraSets : [] });
276
+ else {
277
+ actions.push({
278
+ kind: "syncThemeSets",
279
+ group,
280
+ name,
281
+ add: t.missingSets,
282
+ remove: options.prune ? t.extraSets : [],
283
+ });
284
+ }
148
285
  }
149
286
  // A file that has no active theme gets Light, so the tokens take part in resolution; a file that already has one keeps its choice.
150
287
  if (createdThemes.size > 0 && !live.themes.some((t) => t.active)) {
151
288
  const first = expected.themes.find((t) => t.name === "Light") ?? expected.themes[0];
152
- if (first && createdThemes.has(first.name))
153
- actions.push({ kind: "activateTheme", group: first.group, name: first.name });
289
+ if (first && createdThemes.has(first.name)) {
290
+ actions.push({
291
+ kind: "activateTheme",
292
+ group: first.group,
293
+ name: first.name,
294
+ });
295
+ }
154
296
  }
155
297
  return actions.filter((a) => a.kind !== "syncThemeSets" || a.add.length > 0 || a.remove.length > 0);
156
298
  }
@@ -171,8 +313,9 @@ export function inBatches(tokens) {
171
313
  current.push(token);
172
314
  bytes += size;
173
315
  }
174
- if (current.length > 0)
316
+ if (current.length > 0) {
175
317
  batches.push(current);
318
+ }
176
319
  return batches;
177
320
  }
178
321
  const findSet = `const found = penpot.library.local.tokens.sets.filter((s) => s.name === CFG.set);
@@ -186,7 +329,9 @@ if (t.sets.some((s) => s.name === CFG.set)) return { ok: true, existed: true };
186
329
  const set = t.addSet({ name: CFG.set });
187
330
  // A new set must not take part in resolution before a theme says so.
188
331
  if (set.active) set.toggleActive();
189
- return { ok: true, active: !!set.active };`, { set: action.set });
332
+ return { ok: true, active: !!set.active };`, {
333
+ set: action.set,
334
+ });
190
335
  case "addTokens":
191
336
  return snippet("marwes-add-tokens", `${findSet}
192
337
  const failed = [];
@@ -194,7 +339,10 @@ for (const t of CFG.tokens) {
194
339
  try { if (set.tokens.some((x) => x.name === t.name)) { failed.push(t.name + ": already there"); continue; } set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" }); }
195
340
  catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
196
341
  }
197
- return { ok: true, done: CFG.tokens.length - failed.length, failed };`, { set: action.set, tokens });
342
+ return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
343
+ set: action.set,
344
+ tokens,
345
+ });
198
346
  case "updateTokens":
199
347
  return snippet("marwes-update-tokens", `${findSet}
200
348
  const failed = [];
@@ -203,7 +351,10 @@ for (const t of CFG.tokens) {
203
351
  if (found.length !== 1) { failed.push(t.name + ": not found or ambiguous"); continue; }
204
352
  try { found[0].value = t.value; found[0].description = t.description || ""; } catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
205
353
  }
206
- return { ok: true, done: CFG.tokens.length - failed.length, failed };`, { set: action.set, tokens });
354
+ return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
355
+ set: action.set,
356
+ tokens,
357
+ });
207
358
  case "replaceTokens":
208
359
  return snippet("marwes-replace-tokens", `${findSet}
209
360
  const failed = [];
@@ -215,7 +366,10 @@ for (const t of CFG.tokens) {
215
366
  set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" });
216
367
  } catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
217
368
  }
218
- return { ok: true, done: CFG.tokens.length - failed.length, failed };`, { set: action.set, tokens });
369
+ return { ok: true, done: CFG.tokens.length - failed.length, failed };`, {
370
+ set: action.set,
371
+ tokens,
372
+ });
219
373
  case "removeTokens":
220
374
  return snippet("marwes-remove-tokens", `${findSet}
221
375
  const failed = [];
@@ -224,13 +378,20 @@ for (const name of CFG.names) {
224
378
  if (found.length !== 1) { failed.push(name + ": not found or ambiguous"); continue; }
225
379
  try { found[0].remove(); } catch (error) { failed.push(name + ": " + String(error && error.message || error).slice(0, 120)); }
226
380
  }
227
- return { ok: true, done: CFG.names.length - failed.length, failed };`, { set: action.set, names: action.names });
381
+ return { ok: true, done: CFG.names.length - failed.length, failed };`, {
382
+ set: action.set,
383
+ names: action.names,
384
+ });
228
385
  case "createTheme":
229
386
  return snippet("marwes-create-theme", `const t = penpot.library.local.tokens;
230
387
  if (t.themes.some((x) => x.name === CFG.name && x.group === CFG.group)) throw new Error("theme exists: " + CFG.name);
231
388
  const theme = t.addTheme({ group: CFG.group, name: CFG.name });
232
389
  for (const name of CFG.sets) { const s = t.sets.filter((x) => x.name === name); if (s.length !== 1) throw new Error("set missing or ambiguous: " + name); theme.addSet(s[0]); }
233
- return { ok: true, sets: (theme.activeSets || []).map((x) => x.name) };`, { group: action.group, name: action.name, sets: action.sets });
390
+ return { ok: true, sets: (theme.activeSets || []).map((x) => x.name) };`, {
391
+ group: action.group,
392
+ name: action.name,
393
+ sets: action.sets,
394
+ });
234
395
  case "syncThemeSets":
235
396
  return snippet("marwes-sync-theme", `const t = penpot.library.local.tokens;
236
397
  const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
@@ -238,157 +399,329 @@ if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.nam
238
399
  const pick = (name) => { const s = t.sets.filter((x) => x.name === name); if (s.length !== 1) throw new Error("set missing or ambiguous: " + name); return s[0]; };
239
400
  for (const name of CFG.add) theme[0].addSet(pick(name));
240
401
  for (const name of CFG.remove) theme[0].removeSet(pick(name));
241
- return { ok: true, sets: (theme[0].activeSets || []).map((x) => x.name) };`, { group: action.group, name: action.name, add: action.add, remove: action.remove });
402
+ return { ok: true, sets: (theme[0].activeSets || []).map((x) => x.name) };`, {
403
+ group: action.group,
404
+ name: action.name,
405
+ add: action.add,
406
+ remove: action.remove,
407
+ });
242
408
  case "activateTheme":
243
409
  return snippet("marwes-activate-theme", `const t = penpot.library.local.tokens;
244
410
  if (t.themes.some((x) => x.active)) return { ok: true, alreadyActive: true };
245
411
  const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
246
412
  if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.name);
247
413
  theme[0].toggleActive();
248
- return { ok: true, active: !!theme[0].active };`, { group: action.group, name: action.name });
414
+ return { ok: true, active: !!theme[0].active };`, {
415
+ group: action.group,
416
+ name: action.name,
417
+ });
249
418
  }
250
419
  }
251
420
  const describeAction = (action, count) => {
252
421
  switch (action.kind) {
253
- case "createSet": return `create set ${action.set}`;
254
- case "addTokens": return `add ${count ?? action.tokens.length} token(s) to ${action.set}`;
255
- case "updateTokens": return `update ${count ?? action.tokens.length} token(s) in ${action.set}`;
256
- case "replaceTokens": return `replace ${count ?? action.tokens.length} token(s) in ${action.set}`;
257
- case "removeTokens": return `remove ${action.names.length} token(s) from ${action.set}`;
258
- case "createTheme": return `create theme ${action.group}/${action.name}`;
259
- case "syncThemeSets": return `theme ${action.group}/${action.name}: add ${action.add.length}, remove ${action.remove.length} set(s)`;
260
- case "activateTheme": return `activate theme ${action.name}`;
422
+ case "createSet":
423
+ return `create set ${action.set}`;
424
+ case "addTokens":
425
+ return `add ${count ?? action.tokens.length} token(s) to ${action.set}`;
426
+ case "updateTokens":
427
+ return `update ${count ?? action.tokens.length} token(s) in ${action.set}`;
428
+ case "replaceTokens":
429
+ return `replace ${count ?? action.tokens.length} token(s) in ${action.set}`;
430
+ case "removeTokens":
431
+ return `remove ${action.names.length} token(s) from ${action.set}`;
432
+ case "createTheme":
433
+ return `create theme ${action.group}/${action.name}`;
434
+ case "syncThemeSets":
435
+ return `theme ${action.group}/${action.name}: add ${action.add.length}, remove ${action.remove.length} set(s)`;
436
+ case "activateTheme":
437
+ return `activate theme ${action.name}`;
261
438
  }
262
439
  };
263
440
  export const describePlan = (actions) => actions.map((a) => describeAction(a));
264
441
  export async function readLive(client) {
265
- const record = await runSnippet(client, readTokens(), { effect: "read" });
266
- if (record.ok !== true || !Array.isArray(record.sets) || !Array.isArray(record.themes))
442
+ const record = await runSnippet(client, readTokens(), {
443
+ effect: "read",
444
+ });
445
+ if (record.ok !== true || !Array.isArray(record.sets) || !Array.isArray(record.themes)) {
267
446
  throw new Error("Token readback did not provide explicit sets and themes; synchronization is not verified");
268
- const sets = record.sets, themes = record.themes;
447
+ }
448
+ const sets = record.sets;
449
+ const themes = record.themes;
269
450
  const setNames = new Set();
270
451
  let count = 0;
271
452
  for (const set of sets) {
272
- if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens))
453
+ if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens)) {
273
454
  throw new Error("Invalid or ambiguous token set observation");
455
+ }
274
456
  setNames.add(set.name);
275
457
  const names = new Set();
276
458
  for (const token of set.tokens) {
277
- if (++count > 50000 || typeof token.name !== "string" || names.has(token.name) || typeof token.type !== "string" || token.value === undefined || typeof token.description !== "string")
459
+ if (++count > 50000 ||
460
+ typeof token.name !== "string" ||
461
+ names.has(token.name) ||
462
+ typeof token.type !== "string" ||
463
+ token.value === undefined ||
464
+ typeof token.description !== "string") {
278
465
  throw new Error("Incomplete, ambiguous or over-budget token observation");
466
+ }
279
467
  names.add(token.name);
280
468
  }
281
469
  }
282
470
  const themeNames = new Set();
283
471
  for (const theme of themes) {
284
- const key = JSON.stringify([theme.group, theme.name]);
285
- if (typeof theme.group !== "string" || typeof theme.name !== "string" || themeNames.has(key) || !Array.isArray(theme.sets) || theme.sets.some(name => typeof name !== "string" || !setNames.has(name)) || new Set(theme.sets).size !== theme.sets.length)
472
+ const key = JSON.stringify([
473
+ theme.group,
474
+ theme.name,
475
+ ]);
476
+ if (typeof theme.group !== "string" ||
477
+ typeof theme.name !== "string" ||
478
+ themeNames.has(key) ||
479
+ !Array.isArray(theme.sets) ||
480
+ theme.sets.some((name) => typeof name !== "string" || !setNames.has(name)) ||
481
+ new Set(theme.sets).size !== theme.sets.length) {
286
482
  throw new Error("Invalid theme membership observation");
483
+ }
287
484
  themeNames.add(key);
288
485
  }
289
- return { sets, themes };
486
+ return {
487
+ sets,
488
+ themes,
489
+ };
290
490
  }
291
491
  /**
292
492
  * Carries out the plan one write at a time, stopping at the first anomaly, then reads everything back: the file must now match the
293
493
  * expectation, and no shape may have moved or been rebound.
294
494
  */
295
495
  export async function applyPlan(client, expected, actions, onStep, options = {}) {
296
- if (options.expectedFileId)
297
- client = guardFile(client, { id: options.expectedFileId });
298
- if (options.journal)
299
- client = guardFile(client, { id: (await readJournalTarget(client)).fileId });
496
+ if (options.expectedFileId) {
497
+ client = guardFile(client, {
498
+ id: options.expectedFileId,
499
+ });
500
+ }
501
+ if (options.journal) {
502
+ client = guardFile(client, {
503
+ id: (await readJournalTarget(client)).fileId,
504
+ });
505
+ }
300
506
  let layout;
301
507
  const settle = async () => {
302
- if (options.layout === false)
508
+ if (options.layout === false) {
303
509
  return;
510
+ }
304
511
  layout = await waitForStableLayout(client, options.layout);
305
- if (!layout.stable)
512
+ if (!layout.stable) {
306
513
  throw new Error(`Layout did not settle during token verification: ${layout.reason ?? "unknown reason"}`);
514
+ }
307
515
  };
308
516
  await settle();
309
517
  const fingerprint = async () => {
310
- const r = await runSnippet(client, structureFingerprint(), { effect: "read" });
311
- if (r.ok !== true || !Number.isInteger(r.count) || Number(r.count) < 0 || typeof r.hash !== "string" || !r.hash)
518
+ const r = await runSnippet(client, structureFingerprint(), {
519
+ effect: "read",
520
+ });
521
+ if (r.ok !== true ||
522
+ !Number.isInteger(r.count) ||
523
+ Number(r.count) < 0 ||
524
+ typeof r.hash !== "string" ||
525
+ !r.hash) {
312
526
  throw new Error("Structure readback did not provide a count and hash; nothing can be verified");
313
- return { count: Number(r.count), hash: String(r.hash) };
527
+ }
528
+ return {
529
+ count: Number(r.count),
530
+ hash: String(r.hash),
531
+ };
314
532
  };
315
533
  const structureBefore = await fingerprint();
316
534
  const initialLive = await readLive(client);
317
- for (const action of actions)
318
- if (action.kind === "removeTokens" && action.names.some(name => !options.ancestor?.sets.find(set => set.name === action.set)?.tokens.some(token => token.name === name)))
535
+ for (const action of actions) {
536
+ if (action.kind === "removeTokens" &&
537
+ action.names.some((name) => !options.ancestor?.sets
538
+ .find((set) => set.name === action.set)
539
+ ?.tokens.some((token) => token.name === name))) {
319
540
  throw new Error("Prune refuses tokens absent from the verified ownership ancestor");
541
+ }
542
+ }
320
543
  assertSafeDestruction(actions, initialLive, await impactOf(client));
321
544
  if (options.journal) {
322
545
  const target = await readJournalTarget(client);
323
- const guarded = guardFile(client, { id: target.fileId });
546
+ const guarded = guardFile(client, {
547
+ id: target.fileId,
548
+ });
324
549
  let originalStructure = structureBefore;
325
550
  if (options.journal.resume) {
326
- if (!options.journal.runId)
551
+ if (!options.journal.runId) {
327
552
  throw new Error("Resume requires a run ID");
553
+ }
328
554
  const prior = await readJournal(options.journal.root, options.journal.runId);
329
555
  const payload = prior.payload;
330
- if (!payload || !Array.isArray(payload.actions) || !payload.structure || typeof payload.structure.hash !== "string")
556
+ if (!payload ||
557
+ !Array.isArray(payload.actions) ||
558
+ !payload.structure ||
559
+ typeof payload.structure.hash !== "string") {
331
560
  throw new Error("Token journal has no original action/structure plan");
561
+ }
332
562
  actions = payload.actions;
333
563
  originalStructure = payload.structure;
334
564
  }
335
- if (structureBefore.hash !== originalStructure.hash)
565
+ if (structureBefore.hash !== originalStructure.hash) {
336
566
  throw new Error("Shapes changed since the token run began; reconcile the design before resuming");
337
- const baseline = { live: await readLive(guarded), structure: structureBefore };
338
- if (!options.journal.resume && options.expectedLive && observationHash(options.expectedLive) !== observationHash(baseline.live))
567
+ }
568
+ const baseline = {
569
+ live: await readLive(guarded),
570
+ structure: structureBefore,
571
+ };
572
+ if (!options.journal.resume &&
573
+ options.expectedLive &&
574
+ observationHash(options.expectedLive) !== observationHash(baseline.live)) {
339
575
  throw new Error("Token source changed since the CLI plan; inspect again before writing");
340
- const work = actions.flatMap((action) => "tokens" in action ? inBatches(action.tokens).map((tokens) => ({ ...action, tokens })) : [action]);
576
+ }
577
+ const work = actions.flatMap((action) => "tokens" in action
578
+ ? inBatches(action.tokens).map((tokens) => ({
579
+ ...action,
580
+ tokens,
581
+ }))
582
+ : [
583
+ action,
584
+ ]);
341
585
  const record = await runJournaledSteps(work.map((action, index) => ({
342
- key: `step-${index}-${observationHash(action).slice(0, 12)}`, label: describeAction(action), signature: [actionSnippet(action, "tokens" in action ? action.tokens : undefined).code, actionSatisfied.toString(), reconcileAction.toString(), remainingAction.toString(), waitForStableLayout.toString()].join("\n"),
586
+ key: `step-${index}-${observationHash(action).slice(0, 12)}`,
587
+ label: describeAction(action),
588
+ signature: [
589
+ actionSnippet(action, "tokens" in action ? action.tokens : undefined).code,
590
+ actionSatisfied.toString(),
591
+ reconcileAction.toString(),
592
+ remainingAction.toString(),
593
+ waitForStableLayout.toString(),
594
+ ].join("\n"),
343
595
  initial: baseline,
344
- inspect: async () => { await settle(); return { live: await readLive(guarded), structure: await fingerprint() }; },
596
+ inspect: async () => {
597
+ await settle();
598
+ return {
599
+ live: await readLive(guarded),
600
+ structure: await fingerprint(),
601
+ };
602
+ },
345
603
  apply: async (current, target) => {
346
604
  const residual = remainingAction(action, current.live);
347
- if (!residual)
348
- return { ok: true, reconciled: true };
605
+ if (!residual) {
606
+ return {
607
+ ok: true,
608
+ reconciled: true,
609
+ };
610
+ }
349
611
  const mutation = actionSnippet(residual, "tokens" in residual ? residual.tokens : undefined);
350
612
  const sourceGuard = `${destructionGuard(residual)}\n{ const observed = await (async () => { ${readTokens().code} })();
351
613
  if (JSON.stringify({sets:observed.sets, themes:observed.themes}) !== ${JSON.stringify(JSON.stringify(current.live))}) return {ok:false, message:"Token source changed before mutation"}; }\n`;
352
- return runSnippet(guardRevision(guarded, target), { ...mutation, code: sourceGuard + mutation.code }, { effect: "write" });
614
+ return runSnippet(guardRevision(guarded, target), {
615
+ ...mutation,
616
+ code: sourceGuard + mutation.code,
617
+ }, {
618
+ effect: "write",
619
+ });
620
+ },
621
+ verify: (current) => current.structure.hash === originalStructure.hash &&
622
+ actionSatisfied(action, current.live),
623
+ reconcile: (before, current) => current.structure.hash !== originalStructure.hash
624
+ ? "conflict"
625
+ : reconcileAction(action, before.live, current.live),
626
+ })), {
627
+ ...options.journal,
628
+ coordinate: writeCoordinator(guarded, {
629
+ root: options.journal.root,
630
+ operation: "tokens-push",
631
+ forceLock: options.journal.forceLock,
632
+ warn: onStep,
633
+ }),
634
+ operation: "tokens-push",
635
+ input: {
636
+ snapshot: observationHash(expected),
637
+ ancestor: options.ancestor ? observationHash(options.ancestor) : null,
638
+ prune: options.prune === true,
639
+ layout: options.layout ?? {},
640
+ },
641
+ payload: {
642
+ actions,
643
+ structure: originalStructure,
644
+ },
645
+ target: () => readJournalTarget(guarded),
646
+ onStep,
647
+ finalVerify: async () => {
648
+ await settle();
649
+ return (compareToPenpot(expected, await readLive(guarded), options).inSync &&
650
+ (await fingerprint()).hash === originalStructure.hash);
353
651
  },
354
- verify: (current) => current.structure.hash === originalStructure.hash && actionSatisfied(action, current.live),
355
- reconcile: (before, current) => current.structure.hash !== originalStructure.hash ? "conflict" : reconcileAction(action, before.live, current.live),
356
- })), { ...options.journal, coordinate: writeCoordinator(guarded, { root: options.journal.root, operation: "tokens-push", forceLock: options.journal.forceLock, warn: onStep }), operation: "tokens-push", input: { snapshot: observationHash(expected), ancestor: options.ancestor ? observationHash(options.ancestor) : null, prune: options.prune === true, layout: options.layout ?? {} }, payload: { actions, structure: originalStructure }, target: () => readJournalTarget(guarded), onStep,
357
- finalVerify: async () => { await settle(); return compareToPenpot(expected, await readLive(guarded), options).inSync && (await fingerprint()).hash === originalStructure.hash; },
358
652
  });
359
653
  await settle();
360
654
  const structureAfter = await fingerprint();
361
655
  const drift = compareToPenpot(expected, await readLive(guarded), options);
362
- return { layout, steps: record.steps.map((step) => step.label), structureBefore: originalStructure, structureAfter, structureUnchanged: originalStructure.hash === structureAfter.hash, drift, ...(record.redacted ? { redacted: true, complete: false } : {}), verified: !record.redacted && drift.inSync && originalStructure.hash === structureAfter.hash, journal: { runId: record.runId, state: record.state } };
656
+ return {
657
+ layout,
658
+ steps: record.steps.map((step) => step.label),
659
+ structureBefore: originalStructure,
660
+ structureAfter,
661
+ structureUnchanged: originalStructure.hash === structureAfter.hash,
662
+ drift,
663
+ ...(record.redacted
664
+ ? {
665
+ redacted: true,
666
+ complete: false,
667
+ }
668
+ : {}),
669
+ verified: !record.redacted && drift.inSync && originalStructure.hash === structureAfter.hash,
670
+ journal: {
671
+ runId: record.runId,
672
+ state: record.state,
673
+ },
674
+ };
363
675
  }
364
676
  const steps = [];
365
677
  for (const action of actions) {
366
- const work = "tokens" in action ? inBatches(action.tokens) : [undefined];
678
+ const work = "tokens" in action
679
+ ? inBatches(action.tokens)
680
+ : [
681
+ undefined,
682
+ ];
367
683
  for (const batch of work) {
368
684
  const message = describeAction(action, batch?.length);
369
685
  onStep?.(message);
370
686
  const mutation = actionSnippet(action, batch);
371
- const result = await runSnippet(client, { ...mutation, code: destructionGuard(action) + mutation.code }, { effect: "write" });
687
+ const result = await runSnippet(client, {
688
+ ...mutation,
689
+ code: destructionGuard(action) + mutation.code,
690
+ }, {
691
+ effect: "write",
692
+ });
372
693
  const failed = result.failed ?? [];
373
- if (failed.length > 0)
694
+ if (failed.length > 0) {
374
695
  throw new Error(`${message}: ${failed.join(" | ")}. Nothing after this was written.`);
696
+ }
375
697
  steps.push(message);
376
698
  }
377
699
  }
378
700
  await settle();
379
701
  const structureAfter = await fingerprint();
380
702
  const drift = compareToPenpot(expected, await readLive(client), options);
381
- return { layout, steps, structureBefore, structureAfter, structureUnchanged: structureBefore.hash === structureAfter.hash, drift, verified: drift.inSync && structureBefore.hash === structureAfter.hash };
703
+ return {
704
+ layout,
705
+ steps,
706
+ structureBefore,
707
+ structureAfter,
708
+ structureUnchanged: structureBefore.hash === structureAfter.hash,
709
+ drift,
710
+ verified: drift.inSync && structureBefore.hash === structureAfter.hash,
711
+ };
382
712
  }
383
713
  function destructionGuard(action) {
384
- if (action.kind !== "removeTokens" && action.kind !== "replaceTokens")
714
+ if (action.kind !== "removeTokens" && action.kind !== "replaceTokens") {
385
715
  return "";
716
+ }
386
717
  return `{ const live=await(async()=>{${readTokens().code}})(); const bindings=await(async()=>{${bindingCounts().code}})();
387
718
  (${assertSafeDestruction.toString()})([${JSON.stringify(action)}],live,bindings.counts); }\n`;
388
719
  }
389
720
  /** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
390
721
  export async function impactOf(client) {
391
- const record = await runSnippet(client, bindingCounts(), { effect: "read" });
722
+ const record = await runSnippet(client, bindingCounts(), {
723
+ effect: "read",
724
+ });
392
725
  return record.counts;
393
726
  }
394
727
  export { canonical };