@penpair/marwes-ui 0.1.3 → 0.3.0

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,5 +1,7 @@
1
- import { runSnippet, snippet } from "@penpair/core";
2
- import { canonical, normalizeValue } from "./tokens.js";
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";
4
+ import { actionSatisfied, reconcileAction, remainingAction } from "./reconciliation.js";
3
5
  export function readTokens() {
4
6
  return snippet("marwes-tokens-read", `const tokens = penpot.library.local.tokens;
5
7
  const set = (s, i) => ({ order: i, name: s.name, active: !!s.active, tokens: s.tokens.map((t) => ({ name: t.name, type: t.type, value: t.value, description: t.description || "" })) });
@@ -8,9 +10,10 @@ return { ok: true, readOnly: true, sets: tokens.sets.map(set),
8
10
  }
9
11
  /** How many shapes use each token, over every page: what a change of a token would reach. */
10
12
  export function bindingCounts() {
11
- return snippet("marwes-tokens-bindings", `const counts = {}; let shapes = 0;
12
- const visit = (s) => { shapes++; const b = s.tokens; if (b && typeof b === "object") for (const key of Object.keys(b)) { const name = b[key]; if (typeof name === "string") counts[name] = (counts[name] || 0) + 1; } (s.children || []).forEach(visit); };
13
- for (const page of penpot.currentFile.pages) (page.root.children || []).forEach(visit);
13
+ return snippet("marwes-tokens-bindings", `const counts = Object.create(null); let shapes = 0;
14
+ const stack=[],seen=new Set();
15
+ for(const page of penpot.currentFile.pages)for(const shape of page.root.children||[])stack.push(shape);
16
+ while(stack.length){if(++shapes>50000)throw new Error("Binding inventory exceeds 50000 shapes");const s=stack.pop();if(!s.id||seen.has(s.id))throw new Error("Invalid binding inventory identity");seen.add(s.id);const b=s.tokens||{};for(const name of Object.values(b)){if(typeof name!=="string")throw new Error("Unknown token binding");counts[name]=(counts[name]||0)+1;}for(const child of s.children||[])stack.push(child);}
14
17
  return { ok: true, readOnly: true, shapes, counts };`);
15
18
  }
16
19
  /** A hash of everything structural on every page (ids, kinds, geometry, which tokens are bound), not of colours: pushing tokens must not move or rebind a shape. */
@@ -26,54 +29,125 @@ const display = (type, value) => {
26
29
  return normal.length > 90 ? `${normal.slice(0, 87)}...` : normal;
27
30
  };
28
31
  /** Compares the committed expectation with what the file holds. Only the sets the expectation names are judged; other sets are the project's own. */
29
- export function compareToPenpot(expected, live) {
32
+ export function compareToPenpot(expected, live, options = {}) {
30
33
  const sets = [];
31
34
  for (const wanted of expected.sets) {
32
35
  const found = live.sets.find((s) => s.name === wanted.name);
33
36
  if (!found) {
34
- 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
+ });
35
45
  continue;
36
46
  }
37
47
  const tokens = [];
38
- const have = new Map(found.tokens.map((t) => [t.name, t]));
39
- 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
+ ]));
40
56
  for (const token of wanted.tokens) {
41
57
  const actual = have.get(token.name);
42
- if (!actual)
43
- tokens.push({ name: token.name, kind: "missing" });
44
- else if (actual.type !== token.type)
45
- tokens.push({ name: token.name, kind: "type", expected: token.type, actual: actual.type });
46
- else if (normalizeValue(token.type, token.value) !== normalizeValue(actual.type, actual.value))
47
- tokens.push({ name: token.name, kind: "value", expected: display(token.type, token.value), actual: display(actual.type, actual.value) });
48
- else if (token.description !== actual.description)
49
- tokens.push({ name: token.name, kind: "description", expected: token.description, actual: actual.description });
50
- }
51
- for (const name of have.keys())
52
- if (!want.has(name))
53
- tokens.push({ name, kind: "extra" });
54
- if (tokens.length > 0)
55
- 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
+ }
56
103
  }
57
104
  const themes = [];
58
105
  for (const wanted of expected.themes) {
59
106
  const found = live.themes.find((t) => t.group === wanted.group && t.name === wanted.name);
60
107
  if (!found) {
61
- 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
+ });
62
114
  continue;
63
115
  }
64
116
  const missingSets = wanted.sets.filter((s) => !found.sets.includes(s));
65
- const extraSets = found.sets.filter((s) => !wanted.sets.includes(s));
66
- if (missingSets.length || extraSets.length)
67
- themes.push({ theme: `${wanted.group}/${wanted.name}`, missingSets, extraSets });
117
+ const owned = new Set([
118
+ ...expected.sets,
119
+ ...(options.ancestor?.sets ?? []),
120
+ ].map((set) => set.name));
121
+ const extraSets = found.sets.filter((s) => !wanted.sets.includes(s) && owned.has(s));
122
+ if (missingSets.length || extraSets.length) {
123
+ themes.push({
124
+ theme: `${wanted.group}/${wanted.name}`,
125
+ missingSets,
126
+ extraSets,
127
+ });
128
+ }
68
129
  }
69
130
  const managed = new Set(expected.sets.map((s) => s.name));
70
- const looksManaged = (name) => name.startsWith("Marwes · ") || name === `${expected.project} · Light` || name === `${expected.project} · Dark`;
71
- const unexpectedSets = live.sets.map((s) => s.name).filter((name) => looksManaged(name) && !managed.has(name));
72
- return { inSync: sets.length === 0 && themes.length === 0 && unexpectedSets.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
+ };
73
145
  }
74
146
  export function formatDrift(drift, impact, limit = 6) {
75
147
  const lines = [];
76
- 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
+ : "";
77
151
  for (const s of drift.sets) {
78
152
  if (s.missing) {
79
153
  lines.push(`${s.set}: missing in Penpot (${s.tokens.length} tokens)`);
@@ -83,69 +157,142 @@ export function formatDrift(drift, impact, limit = 6) {
83
157
  const extra = s.tokens.filter((t) => t.kind === "extra");
84
158
  const changed = s.tokens.filter((t) => t.kind === "value" || t.kind === "type");
85
159
  const described = s.tokens.filter((t) => t.kind === "description");
86
- const cut = (items) => (items.length > limit ? `${items.slice(0, limit).join("; ")}; and ${items.length - limit} more` : items.join("; "));
87
- 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) {
88
164
  lines.push(`${s.set}: ${missing.length} missing in Penpot: ${cut(missing.map((t) => t.name))}`);
89
- if (changed.length)
165
+ }
166
+ if (changed.length) {
90
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)}`))}`);
91
- if (extra.length)
168
+ }
169
+ if (extra.length) {
92
170
  lines.push(`${s.set}: ${extra.length} not in the expectation (added by hand?): ${cut(extra.map((t) => t.name))}`);
93
- if (described.length)
171
+ }
172
+ if (described.length) {
94
173
  lines.push(`${s.set}: ${described.length} carry an old description (the recorded Marwes version): ${cut(described.map((t) => t.name))}`);
174
+ }
95
175
  }
96
176
  for (const t of drift.themes) {
97
- if (t.missing)
177
+ if (t.missing) {
98
178
  lines.push(`theme ${t.theme}: missing in Penpot`);
99
- else
179
+ }
180
+ else {
100
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
+ }
101
183
  }
102
- for (const name of drift.unexpectedSets)
184
+ for (const name of drift.unexpectedSets) {
103
185
  lines.push(`set ${name}: in Penpot but no longer expected`);
186
+ }
104
187
  return lines;
105
188
  }
106
189
  /** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
107
190
  export function planPush(expected, live, options = {}) {
108
- const drift = compareToPenpot(expected, live);
191
+ const drift = compareToPenpot(expected, live, options);
109
192
  const actions = [];
110
- 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
+ ]));
111
197
  for (const s of drift.sets) {
112
198
  const wanted = wantedSet.get(s.set);
113
199
  if (s.missing) {
114
- 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
+ });
115
208
  continue;
116
209
  }
117
- 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
+ ]));
118
214
  const add = s.tokens.filter((t) => t.kind === "missing").map((t) => byName.get(t.name));
119
215
  // A typography value assigned in place is stored un-normalised by Penpot, so those are removed and added again.
120
- 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));
121
- 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
+ ];
122
227
  const replaced = new Set(replace.map((t) => t.name));
123
- const update = s.tokens.filter((t) => (t.kind === "value" || t.kind === "description") && !replaced.has(t.name)).map((t) => byName.get(t.name));
124
- if (add.length)
125
- actions.push({ kind: "addTokens", set: s.set, tokens: add });
126
- if (update.length)
127
- actions.push({ kind: "updateTokens", set: s.set, tokens: update });
128
- if (replace.length)
129
- 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
+ }
130
252
  const extra = s.tokens.filter((t) => t.kind === "extra").map((t) => t.name);
131
- if (options.prune && extra.length)
132
- actions.push({ kind: "removeTokens", set: s.set, names: extra });
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
+ }
133
263
  }
134
264
  const createdThemes = new Set();
135
265
  for (const t of drift.themes) {
136
266
  const [group, name] = t.theme.split("/");
137
267
  if (t.missing) {
138
- 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
+ });
139
274
  createdThemes.add(name);
140
275
  }
141
- else
142
- 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
+ }
143
285
  }
144
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.
145
287
  if (createdThemes.size > 0 && !live.themes.some((t) => t.active)) {
146
288
  const first = expected.themes.find((t) => t.name === "Light") ?? expected.themes[0];
147
- if (first && createdThemes.has(first.name))
148
- 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
+ }
149
296
  }
150
297
  return actions.filter((a) => a.kind !== "syncThemeSets" || a.add.length > 0 || a.remove.length > 0);
151
298
  }
@@ -166,8 +313,9 @@ export function inBatches(tokens) {
166
313
  current.push(token);
167
314
  bytes += size;
168
315
  }
169
- if (current.length > 0)
316
+ if (current.length > 0) {
170
317
  batches.push(current);
318
+ }
171
319
  return batches;
172
320
  }
173
321
  const findSet = `const found = penpot.library.local.tokens.sets.filter((s) => s.name === CFG.set);
@@ -181,7 +329,9 @@ if (t.sets.some((s) => s.name === CFG.set)) return { ok: true, existed: true };
181
329
  const set = t.addSet({ name: CFG.set });
182
330
  // A new set must not take part in resolution before a theme says so.
183
331
  if (set.active) set.toggleActive();
184
- return { ok: true, active: !!set.active };`, { set: action.set });
332
+ return { ok: true, active: !!set.active };`, {
333
+ set: action.set,
334
+ });
185
335
  case "addTokens":
186
336
  return snippet("marwes-add-tokens", `${findSet}
187
337
  const failed = [];
@@ -189,7 +339,10 @@ for (const t of CFG.tokens) {
189
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 || "" }); }
190
340
  catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
191
341
  }
192
- 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
+ });
193
346
  case "updateTokens":
194
347
  return snippet("marwes-update-tokens", `${findSet}
195
348
  const failed = [];
@@ -198,7 +351,10 @@ for (const t of CFG.tokens) {
198
351
  if (found.length !== 1) { failed.push(t.name + ": not found or ambiguous"); continue; }
199
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)); }
200
353
  }
201
- 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
+ });
202
358
  case "replaceTokens":
203
359
  return snippet("marwes-replace-tokens", `${findSet}
204
360
  const failed = [];
@@ -210,7 +366,10 @@ for (const t of CFG.tokens) {
210
366
  set.addToken({ type: t.type, name: t.name, value: t.value, description: t.description || "" });
211
367
  } catch (error) { failed.push(t.name + ": " + String(error && error.message || error).slice(0, 120)); }
212
368
  }
213
- 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
+ });
214
373
  case "removeTokens":
215
374
  return snippet("marwes-remove-tokens", `${findSet}
216
375
  const failed = [];
@@ -219,13 +378,20 @@ for (const name of CFG.names) {
219
378
  if (found.length !== 1) { failed.push(name + ": not found or ambiguous"); continue; }
220
379
  try { found[0].remove(); } catch (error) { failed.push(name + ": " + String(error && error.message || error).slice(0, 120)); }
221
380
  }
222
- 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
+ });
223
385
  case "createTheme":
224
386
  return snippet("marwes-create-theme", `const t = penpot.library.local.tokens;
225
387
  if (t.themes.some((x) => x.name === CFG.name && x.group === CFG.group)) throw new Error("theme exists: " + CFG.name);
226
388
  const theme = t.addTheme({ group: CFG.group, name: CFG.name });
227
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]); }
228
- 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
+ });
229
395
  case "syncThemeSets":
230
396
  return snippet("marwes-sync-theme", `const t = penpot.library.local.tokens;
231
397
  const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
@@ -233,63 +399,329 @@ if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.nam
233
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]; };
234
400
  for (const name of CFG.add) theme[0].addSet(pick(name));
235
401
  for (const name of CFG.remove) theme[0].removeSet(pick(name));
236
- 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
+ });
237
408
  case "activateTheme":
238
409
  return snippet("marwes-activate-theme", `const t = penpot.library.local.tokens;
239
410
  if (t.themes.some((x) => x.active)) return { ok: true, alreadyActive: true };
240
411
  const theme = t.themes.filter((x) => x.name === CFG.name && x.group === CFG.group);
241
412
  if (theme.length !== 1) throw new Error("theme missing or ambiguous: " + CFG.name);
242
413
  theme[0].toggleActive();
243
- 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
+ });
244
418
  }
245
419
  }
246
420
  const describeAction = (action, count) => {
247
421
  switch (action.kind) {
248
- case "createSet": return `create set ${action.set}`;
249
- case "addTokens": return `add ${count ?? action.tokens.length} token(s) to ${action.set}`;
250
- case "updateTokens": return `update ${count ?? action.tokens.length} token(s) in ${action.set}`;
251
- case "replaceTokens": return `replace ${count ?? action.tokens.length} token(s) in ${action.set}`;
252
- case "removeTokens": return `remove ${action.names.length} token(s) from ${action.set}`;
253
- case "createTheme": return `create theme ${action.group}/${action.name}`;
254
- case "syncThemeSets": return `theme ${action.group}/${action.name}: add ${action.add.length}, remove ${action.remove.length} set(s)`;
255
- 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}`;
256
438
  }
257
439
  };
258
440
  export const describePlan = (actions) => actions.map((a) => describeAction(a));
259
441
  export async function readLive(client) {
260
- const record = await runSnippet(client, readTokens());
261
- return { sets: record.sets, themes: 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)) {
446
+ throw new Error("Token readback did not provide explicit sets and themes; synchronization is not verified");
447
+ }
448
+ const sets = record.sets;
449
+ const themes = record.themes;
450
+ const setNames = new Set();
451
+ let count = 0;
452
+ for (const set of sets) {
453
+ if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens)) {
454
+ throw new Error("Invalid or ambiguous token set observation");
455
+ }
456
+ setNames.add(set.name);
457
+ const names = new Set();
458
+ for (const token of set.tokens) {
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") {
465
+ throw new Error("Incomplete, ambiguous or over-budget token observation");
466
+ }
467
+ names.add(token.name);
468
+ }
469
+ }
470
+ const themeNames = new Set();
471
+ for (const theme of themes) {
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) {
482
+ throw new Error("Invalid theme membership observation");
483
+ }
484
+ themeNames.add(key);
485
+ }
486
+ return {
487
+ sets,
488
+ themes,
489
+ };
262
490
  }
263
491
  /**
264
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
265
493
  * expectation, and no shape may have moved or been rebound.
266
494
  */
267
- export async function applyPlan(client, expected, actions, onStep) {
495
+ export async function applyPlan(client, expected, actions, onStep, options = {}) {
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
+ }
506
+ let layout;
507
+ const settle = async () => {
508
+ if (options.layout === false) {
509
+ return;
510
+ }
511
+ layout = await waitForStableLayout(client, options.layout);
512
+ if (!layout.stable) {
513
+ throw new Error(`Layout did not settle during token verification: ${layout.reason ?? "unknown reason"}`);
514
+ }
515
+ };
516
+ await settle();
268
517
  const fingerprint = async () => {
269
- const r = await runSnippet(client, structureFingerprint());
270
- return { count: Number(r.count), 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) {
526
+ throw new Error("Structure readback did not provide a count and hash; nothing can be verified");
527
+ }
528
+ return {
529
+ count: Number(r.count),
530
+ hash: String(r.hash),
531
+ };
271
532
  };
272
533
  const structureBefore = await fingerprint();
534
+ const initialLive = await readLive(client);
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))) {
540
+ throw new Error("Prune refuses tokens absent from the verified ownership ancestor");
541
+ }
542
+ }
543
+ assertSafeDestruction(actions, initialLive, await impactOf(client));
544
+ if (options.journal) {
545
+ const target = await readJournalTarget(client);
546
+ const guarded = guardFile(client, {
547
+ id: target.fileId,
548
+ });
549
+ let originalStructure = structureBefore;
550
+ if (options.journal.resume) {
551
+ if (!options.journal.runId) {
552
+ throw new Error("Resume requires a run ID");
553
+ }
554
+ const prior = await readJournal(options.journal.root, options.journal.runId);
555
+ const payload = prior.payload;
556
+ if (!payload ||
557
+ !Array.isArray(payload.actions) ||
558
+ !payload.structure ||
559
+ typeof payload.structure.hash !== "string") {
560
+ throw new Error("Token journal has no original action/structure plan");
561
+ }
562
+ actions = payload.actions;
563
+ originalStructure = payload.structure;
564
+ }
565
+ if (structureBefore.hash !== originalStructure.hash) {
566
+ throw new Error("Shapes changed since the token run began; reconcile the design before resuming");
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)) {
575
+ throw new Error("Token source changed since the CLI plan; inspect again before writing");
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
+ ]);
585
+ const record = await runJournaledSteps(work.map((action, index) => ({
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"),
595
+ initial: baseline,
596
+ inspect: async () => {
597
+ await settle();
598
+ return {
599
+ live: await readLive(guarded),
600
+ structure: await fingerprint(),
601
+ };
602
+ },
603
+ apply: async (current, target) => {
604
+ const residual = remainingAction(action, current.live);
605
+ if (!residual) {
606
+ return {
607
+ ok: true,
608
+ reconciled: true,
609
+ };
610
+ }
611
+ const mutation = actionSnippet(residual, "tokens" in residual ? residual.tokens : undefined);
612
+ const sourceGuard = `${destructionGuard(residual)}\n{ const observed = await (async () => { ${readTokens().code} })();
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`;
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);
651
+ },
652
+ });
653
+ await settle();
654
+ const structureAfter = await fingerprint();
655
+ const drift = compareToPenpot(expected, await readLive(guarded), options);
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
+ };
675
+ }
273
676
  const steps = [];
274
677
  for (const action of actions) {
275
- const work = "tokens" in action ? inBatches(action.tokens) : [undefined];
678
+ const work = "tokens" in action
679
+ ? inBatches(action.tokens)
680
+ : [
681
+ undefined,
682
+ ];
276
683
  for (const batch of work) {
277
684
  const message = describeAction(action, batch?.length);
278
685
  onStep?.(message);
279
- const result = await runSnippet(client, actionSnippet(action, batch));
686
+ const mutation = actionSnippet(action, batch);
687
+ const result = await runSnippet(client, {
688
+ ...mutation,
689
+ code: destructionGuard(action) + mutation.code,
690
+ }, {
691
+ effect: "write",
692
+ });
280
693
  const failed = result.failed ?? [];
281
- if (failed.length > 0)
694
+ if (failed.length > 0) {
282
695
  throw new Error(`${message}: ${failed.join(" | ")}. Nothing after this was written.`);
696
+ }
283
697
  steps.push(message);
284
698
  }
285
699
  }
700
+ await settle();
286
701
  const structureAfter = await fingerprint();
287
- const drift = compareToPenpot(expected, await readLive(client));
288
- return { steps, structureBefore, structureAfter, structureUnchanged: structureBefore.hash === structureAfter.hash, drift, verified: drift.inSync && structureBefore.hash === structureAfter.hash };
702
+ const drift = compareToPenpot(expected, await readLive(client), options);
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
+ };
712
+ }
713
+ function destructionGuard(action) {
714
+ if (action.kind !== "removeTokens" && action.kind !== "replaceTokens") {
715
+ return "";
716
+ }
717
+ return `{ const live=await(async()=>{${readTokens().code}})(); const bindings=await(async()=>{${bindingCounts().code}})();
718
+ (${assertSafeDestruction.toString()})([${JSON.stringify(action)}],live,bindings.counts); }\n`;
289
719
  }
290
720
  /** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
291
721
  export async function impactOf(client) {
292
- const record = await runSnippet(client, bindingCounts());
722
+ const record = await runSnippet(client, bindingCounts(), {
723
+ effect: "read",
724
+ });
293
725
  return record.counts;
294
726
  }
295
727
  export { canonical };