@toghen/figma 0.0.0-stage → 0.1.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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1058 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24147 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +548 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The last few resolver documents this user loaded, kept so they can be loaded
3
+ * again without finding the file.
4
+ *
5
+ * ### Why it holds the bundle, not a path or the files
6
+ *
7
+ * A plugin cannot re-read a file from disk. The UI gets a `File` at the moment
8
+ * the user picks it and nothing afterwards — no path, no handle, no second
9
+ * read. So a recents list that stored only a name would be a list you can look
10
+ * at and not click. The bundle travels with the entry: the document the core's
11
+ * `load` returned, every reference to another file already resolved, so it
12
+ * loads again with no file at all. The composition (what that load read, and
13
+ * how) travels beside it, so the screen can still say where it came from.
14
+ *
15
+ * Entries kept before the core loaded documents hold the document's text as
16
+ * `json`. Such an entry is a resolver document by construction; it is read as
17
+ * one, once, and the list is written back in the new shape.
18
+ *
19
+ * ### Why per user rather than per file
20
+ *
21
+ * `figma.clientStorage` is scoped to the person, not the document, and follows
22
+ * them into every file they open — the same argument that put the window size
23
+ * there. Recents are a property of someone's working habits. Putting them in
24
+ * the file instead would publish one person's in-progress token documents to
25
+ * everyone with access to it, and would make the list start empty in each new
26
+ * file, which is the opposite of useful.
27
+ *
28
+ * ### Size
29
+ *
30
+ * Figma documents no quota for `clientStorage`, and the typings state only that
31
+ * `setAsync` "will reject with an error message if storage failed" — so there
32
+ * is no number here to enforce, and inventing one would be a guess dressed as a
33
+ * constraint. Instead the write is attempted and, if it is refused, the oldest
34
+ * entry is dropped and it is attempted again, down to a single entry. A recents
35
+ * list is a convenience; it must never be the reason a load fails.
36
+ */
37
+
38
+ import type { ResolverDocument } from '@toghen/lib';
39
+ import type { Composition, RecentDocumentSummary } from '../../shared/protocol.js';
40
+
41
+ /** Key in `figma.clientStorage`, which is per user rather than per file. */
42
+ export const RECENTS_KEY = 'recentDocuments';
43
+
44
+ /**
45
+ * How many to keep.
46
+ *
47
+ * Enough to cover the systems someone moves between, short enough to read
48
+ * without scanning. It is a list you glance at, not one you search.
49
+ */
50
+ export const MAX_RECENTS = 5;
51
+
52
+ export interface RecentDocument extends RecentDocumentSummary {
53
+ /** The bundle: `load`'s self-contained document, so the entry loads again without its files. */
54
+ document: ResolverDocument;
55
+ /** What the load that stored it read, and how. */
56
+ composition: Composition;
57
+ }
58
+
59
+ /**
60
+ * Reads the list, newest first.
61
+ *
62
+ * Never throws and never rejects. A corrupt or half-written blob reads as "no
63
+ * recents", which costs the user a trip to the file picker — the safe
64
+ * direction, and the one they were taking anyway before this existed.
65
+ */
66
+ export async function loadRecents(): Promise<RecentDocument[]> {
67
+ try {
68
+ const stored: unknown = await figma.clientStorage.getAsync(RECENTS_KEY);
69
+ if (!Array.isArray(stored)) return [];
70
+
71
+ const recents = stored.map(fromOldShape).filter(isRecentDocument).slice(0, MAX_RECENTS);
72
+ if (stored.some(isOldShape)) return await writeAsFarAsItFits(recents);
73
+ return recents;
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Puts an entry at the top of the list and writes it back.
81
+ *
82
+ * Entries are identified by {@link RecentDocument.key}, so loading an edited
83
+ * document replaces its entry and moves it to the front rather than growing a
84
+ * second row that differs only by timestamp. Editing a document and loading it
85
+ * again is a normal working loop, not a new system.
86
+ *
87
+ * @returns the list as stored, so the caller can report it without re-reading.
88
+ */
89
+ export async function rememberRecent(entry: RecentDocument): Promise<RecentDocument[]> {
90
+ const existing = await loadRecents();
91
+ const next = [entry, ...existing.filter((item) => item.key !== entry.key)].slice(0, MAX_RECENTS);
92
+
93
+ return writeAsFarAsItFits(next);
94
+ }
95
+
96
+ /** Drops one entry, by key. */
97
+ export async function forgetRecent(key: string): Promise<RecentDocument[]> {
98
+ const next = (await loadRecents()).filter((item) => item.key !== key);
99
+ return writeAsFarAsItFits(next);
100
+ }
101
+
102
+ /** What the UI needs to draw the list — everything except the document itself. */
103
+ export function summarize(recents: readonly RecentDocument[]): RecentDocumentSummary[] {
104
+ // The documents are deliberately left behind. The UI never needs them, and
105
+ // posting five of them across the iframe boundary on every load is work
106
+ // nobody asked for.
107
+ return recents.map(({ document: _document, composition: _composition, ...summary }) => summary);
108
+ }
109
+
110
+ /**
111
+ * Writes the list, shedding the oldest entries until the write is accepted.
112
+ *
113
+ * `setAsync` rejects when storage fails and names no budget beforehand, so the
114
+ * only way to find the limit is to reach it. Shedding from the end drops the
115
+ * least recently used first, which is the entry a user is least likely to miss.
116
+ */
117
+ async function writeAsFarAsItFits(entries: RecentDocument[]): Promise<RecentDocument[]> {
118
+ for (let count = entries.length; count > 0; count -= 1) {
119
+ const attempt = entries.slice(0, count);
120
+ try {
121
+ await figma.clientStorage.setAsync(RECENTS_KEY, attempt);
122
+ return attempt;
123
+ } catch {
124
+ // Try again with one fewer.
125
+ }
126
+ }
127
+
128
+ try {
129
+ await figma.clientStorage.deleteAsync(RECENTS_KEY);
130
+ } catch {
131
+ // Nothing further to try, and nothing here is worth failing a load over.
132
+ }
133
+ return [];
134
+ }
135
+
136
+ function isRecentDocument(value: unknown): value is RecentDocument {
137
+ if (typeof value !== 'object' || value === null) return false;
138
+
139
+ const entry = value as Partial<RecentDocument>;
140
+ return (
141
+ typeof entry.key === 'string' &&
142
+ typeof entry.name === 'string' &&
143
+ typeof entry.document === 'object' &&
144
+ entry.document !== null &&
145
+ typeof entry.composition === 'object' &&
146
+ entry.composition !== null &&
147
+ typeof entry.loadedAt === 'string'
148
+ );
149
+ }
150
+
151
+ /** An entry kept before the core loaded documents: the document's text as `json`, and no composition. */
152
+ const isOldShape = (value: unknown): boolean =>
153
+ typeof value === 'object' && value !== null && typeof (value as { json?: unknown }).json === 'string';
154
+
155
+ /** An old entry read as the resolver document it always was; any other value as it is. One that does not parse is dropped by the filter. */
156
+ function fromOldShape(value: unknown): unknown {
157
+ if (!isOldShape(value)) return value;
158
+ const { json, ...entry } = value as RecentDocumentSummary & { json: string };
159
+ try {
160
+ const document = JSON.parse(json) as ResolverDocument;
161
+ const composition: Composition = { form: 'resolver', entry: entry.fileName ?? entry.key, reads: [] };
162
+ return { ...entry, document, composition };
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
@@ -0,0 +1,271 @@
1
+ import { InvalidTokenError, referenceTarget, type DesignSystem, type Resolver } from '@toghen/lib';
2
+
3
+ /**
4
+ * A resolver document resolved into its sum: a single multi-mode `DesignSystem`.
5
+ *
6
+ * @see resolveSumModes
7
+ */
8
+ export interface MultiModeResolution {
9
+ /** One collection, one mode per context of each modifier, and the defaults. */
10
+ ds: DesignSystem;
11
+ /** Name of the collection inside `ds` — the leading segment of every token path. */
12
+ dsCollectionName: string;
13
+ /** Mode names: the defaults first, then each other context on its own; the order Figma modes are created in. */
14
+ modeNames: string[];
15
+ /** The mode of the document's default context selections: the core's `getDefaultMode()`. */
16
+ defaultMode: string;
17
+ }
18
+
19
+ /**
20
+ * The Figma collection a document's variables belong in.
21
+ *
22
+ * Shared rather than repeated: the sync plan and the Inspector must look at the
23
+ * *same* collection, or the one describing what is and the one proposing what to
24
+ * do would be talking about two different sets of variables — and neither would
25
+ * have any way to notice.
26
+ */
27
+ export function figmaCollectionNameFor(resolver: Resolver, preferred?: string): string {
28
+ return preferred?.trim() || resolver.name || 'resolved';
29
+ }
30
+
31
+ /**
32
+ * The most modes Figma lets a collection hold, for every file. Figma's limit depends on the plan of
33
+ * the file (4, 10, 20 or 40); Toghen does not read the plan and takes no other number, so what a
34
+ * design system syncs does not depend on who pays for the file.
35
+ */
36
+ export const MAX_MODES = 10;
37
+
38
+ /**
39
+ * The contexts of a modifier that Figma gets, the default first: all of them, or the ones its
40
+ * `$extensions["com.toghen"].figma.include` lists, in the document's order whatever order the list
41
+ * is written in. CSS reads nothing from this key; the contexts it leaves out are still written there.
42
+ *
43
+ * @throws {Error} Naming the modifier, when `include` is not a list of names, names a context the
44
+ * modifier does not have, or leaves out the default context.
45
+ */
46
+ export function figmaContexts(modifier: {
47
+ name: string;
48
+ default?: string;
49
+ contexts: readonly string[];
50
+ $extensions?: Record<string, any>;
51
+ }): string[] {
52
+ // eslint-disable-line @typescript-eslint/no-explicit-any
53
+ // `resolveSumModes` refuses a modifier with no default before any caller reaches here.
54
+ const first = modifier.default!;
55
+ const all = [first, ...modifier.contexts.filter((context) => context !== first)];
56
+ const include: unknown = modifier.$extensions?.['com.toghen']?.figma?.include;
57
+ if (include === undefined) return all;
58
+
59
+ const where = `Modifier "${modifier.name}"`;
60
+ if (!Array.isArray(include) || include.some((name) => typeof name !== 'string')) {
61
+ throw new Error(`${where}: figma.include must be a list of context names.`);
62
+ }
63
+ const unknown = include.filter((name) => !all.includes(name));
64
+ if (unknown.length > 0) {
65
+ throw new Error(
66
+ `${where} lists ${unknown.map((name) => `"${name}"`).join(', ')} in figma.include, which ` +
67
+ `${unknown.length === 1 ? 'is not a context' : 'are not contexts'} of it (it has ${all.map((name) => `"${name}"`).join(', ')}).`,
68
+ );
69
+ }
70
+ if (!include.includes(first)) {
71
+ throw new Error(
72
+ `${where}: figma.include must list its default context "${first}". Figma's default mode is the first ` +
73
+ `one of the collection and is fixed when the collection is made, so the default cannot be left out.`,
74
+ );
75
+ }
76
+ return all.filter((context) => include.includes(context));
77
+ }
78
+
79
+ /** One mode's view of the document: every token path and its resolved value. */
80
+ export interface ModeSnapshot {
81
+ modeName: string;
82
+ pathToToken: Map<string, SnapshotToken>;
83
+ }
84
+
85
+ export interface SnapshotToken {
86
+ value: unknown;
87
+ type: string;
88
+ /** The core's reason, where it cannot compute the value for the type (then `value` is undefined): the token is one skip. */
89
+ refused?: string;
90
+ /**
91
+ * The token this one is, when its whole value is one reference: the target's
92
+ * lookup path, first hop. Syntactic only; whether Figma can express it as an
93
+ * alias is decided where types are known, in `correspondence.ts`.
94
+ */
95
+ aliasOf?: string;
96
+ /**
97
+ * What each member of a composite refers to, when the member is one whole
98
+ * reference: member address (`'fontSize'`, `'0.color'`) → target lookup path.
99
+ * Absent when there are none. Whether Figma can bind it is decided where
100
+ * fields are known, in `styleSyncEngine.ts`.
101
+ */
102
+ memberOf?: Record<string, string>;
103
+ }
104
+
105
+ /**
106
+ * Resolves a document's **sum**: the defaults, and each context of each
107
+ * modifier on its own, in one `DesignSystem`.
108
+ *
109
+ * It has a mode per context, not per combination: what a Figma file with a
110
+ * collection per modifier holds, and the only system the plugin builds, since
111
+ * the product of a large document does not finish. `defaultMode` is the core's
112
+ * answer, `getDefaultMode()`, which the sum places first in `modeNames`.
113
+ *
114
+ * Lib builds the sum with `{}` as its base, which throws for a modifier that
115
+ * declares no `default` (a legal document). The sum needs one for each, so this
116
+ * throws, naming each such modifier; there is no fallback to the product.
117
+ *
118
+ * Built once per document: it is the slow part of a large load.
119
+ */
120
+ export function resolveSumModes(resolver: Resolver): MultiModeResolution {
121
+ const cached = sums.get(resolver);
122
+ if (cached) return cached;
123
+
124
+ const undeclared = resolver
125
+ .getModifiers()
126
+ .filter((modifier) => modifier.default === undefined)
127
+ .map((modifier) => modifier.name);
128
+ if (undeclared.length > 0) {
129
+ throw new Error(
130
+ undeclared.map((name) => `Modifier "${name}" declares no default context.`).join(' ') +
131
+ ` The sum of the contexts needs a default for each modifier: its Figma collection starts with the default ` +
132
+ `context, because Figma's default mode is fixed when the collection is made. Declare a "default" ` +
133
+ `for ${undeclared.length === 1 ? 'it' : 'each'} and load the document again.`,
134
+ );
135
+ }
136
+
137
+ const ds = resolver.resolveContextsToDesignSystem();
138
+ const dsCollectionName = ds.getCollectionNames()[0];
139
+ const collection = ds.getCollection(dsCollectionName);
140
+ const modeNames = collection?.getModes() ?? [];
141
+
142
+ const resolution = { ds, dsCollectionName, modeNames, defaultMode: collection?.getDefaultMode() ?? '' };
143
+ sums.set(resolver, resolution);
144
+ return resolution;
145
+ }
146
+
147
+ /** The sums already built, by document. A loaded document does not change. */
148
+ const sums = new WeakMap<Resolver, MultiModeResolution>();
149
+
150
+ /**
151
+ * Reads every mode of a resolved system into a flat path → value map.
152
+ *
153
+ * References are expanded per mode, so a semantic token pointing at different
154
+ * primitives in `light` and `dark` yields the two different concrete values.
155
+ * Where a mode's whole value is one reference, `aliasOf` keeps what resolution
156
+ * erased, and `memberOf` does the same for a composite's members; the library
157
+ * says so, since the plugin never reads `{...}` itself.
158
+ *
159
+ * @param resolution - Output of {@link resolveSumModes}
160
+ * @returns One snapshot per mode, in `modeNames` order
161
+ */
162
+ export function snapshotModes(resolution: MultiModeResolution): ModeSnapshot[] {
163
+ const { ds, dsCollectionName, modeNames } = resolution;
164
+ const collection = ds.getCollection(dsCollectionName);
165
+ if (!collection) return [];
166
+
167
+ return modeNames.map((modeName) => {
168
+ const pathToToken = new Map<string, SnapshotToken>();
169
+
170
+ for (const tokenPath of collection.getAllPaths(modeName)) {
171
+ const fullPath = `${dsCollectionName}.${tokenPath}`;
172
+ const token = readToken(ds, fullPath, modeName);
173
+ if (!token) continue;
174
+
175
+ const references = ds.referencesOf(fullPath, modeName);
176
+ const whole = references.length === 1 && references[0].at === '' && !references[0].interpolated;
177
+ const memberOf: Record<string, string> = {};
178
+ for (const ref of references) {
179
+ if (ref.at !== '' && !ref.interpolated) memberOf[ref.at] = ref.to;
180
+ }
181
+ pathToToken.set(fullPath, {
182
+ value: token.$value,
183
+ type: token.$type,
184
+ ...(token.refused === undefined ? {} : { refused: token.refused }),
185
+ ...(whole ? { aliasOf: references[0].to } : {}),
186
+ ...(Object.keys(memberOf).length > 0 ? { memberOf } : {}),
187
+ });
188
+ }
189
+
190
+ return { modeName, pathToToken };
191
+ });
192
+ }
193
+
194
+ /**
195
+ * A token as the core resolves it in one mode, or null when it is not there. Where the core refuses to compute the
196
+ * value for the type (a typed `dimension` that resolves to `8px 13px`), its type and the core's reason instead, so
197
+ * that one token is one skip and not the stop of a plan. Any other failure is thrown.
198
+ *
199
+ * The core gives a token's type only with its value, so where it refuses the value the type is read here as the core
200
+ * reads it (W3C DTCG §5.2.2): the token's own, else the type of the token its whole value refers to, else its
201
+ * nearest group's.
202
+ */
203
+ export function readToken(ds: DesignSystem, path: string, mode: string): { $type: string; $value?: unknown; refused?: string } | null {
204
+ try {
205
+ return ds.resolveToken(path, mode);
206
+ } catch (error) {
207
+ if (!(error instanceof InvalidTokenError)) throw error;
208
+ return { $type: typeOfRefused(ds, path, mode), refused: error.message };
209
+ }
210
+ }
211
+
212
+ function typeOfRefused(ds: DesignSystem, path: string, mode: string): string {
213
+ const lookup = ds.lookupPath(path);
214
+ const name = lookup.slice(0, lookup.indexOf('.'));
215
+ const tokenPath = lookup.slice(name.length + 1);
216
+ const collection = ds.getCollection(name);
217
+ const raw = collection?.getToken(tokenPath, mode);
218
+ if (!collection || !raw) return '';
219
+ const target = referenceTarget(raw.$value);
220
+ const through = target === undefined ? '' : (readToken(ds, `${name}.${target}`, mode)?.$type ?? '');
221
+ if (raw.$type || through) return raw.$type || through;
222
+ const segments = tokenPath.split('.');
223
+ for (let i = segments.length - 1; i > 0; i -= 1) {
224
+ const type = collection.getGroupAttribute(segments.slice(0, i).join('.'), '$type', mode);
225
+ if (type !== null) return type as string;
226
+ }
227
+ return '';
228
+ }
229
+
230
+ /**
231
+ * Every token path of the sum that has a value in at least one of `modes` (all of them when none are given), with
232
+ * the modes it has one in. Reference paths, in order of first appearance, so a plan is stable between runs; the
233
+ * modes in `modeNames` order. Planning from one mode's paths is what made mode-only tokens vanish without a warning.
234
+ *
235
+ * The core walks every mode only privately (`getAllPaths()` takes one), and this adapter is its one consumer, so
236
+ * the walk is here, once: the sum, the token graph, the document's counts and the component files all call it.
237
+ */
238
+ export function pathsInAnyMode(resolution: MultiModeResolution, modes: readonly string[] = resolution.modeNames): Map<string, string[]> {
239
+ const collection = resolution.ds.getCollection(resolution.dsCollectionName);
240
+ const found = new Map<string, string[]>();
241
+ for (const mode of modes) {
242
+ for (const path of collection?.getAllPaths(mode) ?? []) (found.get(path) ?? found.set(path, []).get(path)!).push(mode);
243
+ }
244
+ return found;
245
+ }
246
+
247
+ /** A reference to a token: who refers (a lookup path), and where in its value (`''` for the whole value, `fontSize` for a member). */
248
+ export interface Referrer {
249
+ from: string;
250
+ at: string;
251
+ }
252
+
253
+ /**
254
+ * Who refers to each token, in the modes `pathsInAnyMode` gave: target lookup path → each referrer, once per member.
255
+ * The core answers what one token refers to (`referencesOf()`); the reverse has one consumer, this adapter, so it
256
+ * is built here, once, for the sum's purposes and the token graph alike.
257
+ */
258
+ export function referrersOf(resolution: MultiModeResolution, paths: ReadonlyMap<string, readonly string[]>): Map<string, Referrer[]> {
259
+ const { ds, dsCollectionName } = resolution;
260
+ const referrers = new Map<string, Referrer[]>();
261
+ for (const [path, modes] of paths) {
262
+ const from = `${dsCollectionName}.${path}`;
263
+ for (const mode of modes) {
264
+ for (const { at, to } of ds.referencesOf(from, mode)) {
265
+ const into = referrers.get(to) ?? referrers.set(to, []).get(to)!;
266
+ if (!into.some((known) => known.from === from && known.at === at)) into.push({ from, at });
267
+ }
268
+ }
269
+ }
270
+ return referrers;
271
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The root record of an older build, turned into stamps once (decisions, *Ruled: the stamp, one receipt per object,
3
+ * for every target*).
4
+ *
5
+ * The record named, per token path, the variable or style written for it and a hash of the token's value per mode
6
+ * name. A stamp hashes what Figma reports right after a write, and what Figma holds now is no proof of that: a hand
7
+ * edit made before the upgrade, which the old build kept while the document stood still, would read as written and be
8
+ * overwritten. So a field the record says was written is taken as written only where Figma holds the document's value,
9
+ * a variable mode by mode and a style field by field; any other is stamped `UNPROVEN`, a hash no report has, and reads
10
+ * as drift, kept and reported. Where the document moved since the write, the record's hash cannot say what
11
+ * Figma held then, so a field Figma does not agree on is drift too, hand edit or not; `--overwrite-drift` writes it.
12
+ */
13
+
14
+ import type { Resolver } from '@toghen/lib';
15
+ import type { TokenTypeConversionMap } from '../../shared/tokenTypeMap.js';
16
+ import { getStamp, hashOf, receiptOf, setStamp, UNPROVEN, type Stamp, type Stamped } from '../components/stamp.js';
17
+ import { holds, StyleSyncEngine, wantedOf } from '../engines/styleSyncEngine.js';
18
+ import { setState } from '../runtimeState.js';
19
+ import { VariableSyncEngine } from '../engines/variableSyncEngine.js';
20
+ import { figmaCollectionNameFor } from './resolverModes.js';
21
+ import { clearSharded, isPlainObject, readSharded } from './shardedData.js';
22
+ import { reportedScopes, reportOf, type StyleKind } from './tokenStamps.js';
23
+
24
+ /** Where older builds kept the root record. */
25
+ const RECORD_KEY = 'extensions';
26
+
27
+ /** Puts a stamp where the plans read it, before it is written: in `unwrittenStamps`, by the object's id. */
28
+ type Put = (object: Stamped, stamp: Stamp) => void;
29
+
30
+ /**
31
+ * Turns the root record into stamps, then deletes it with its shards. One way only. The scopes the record kept are what
32
+ * Toghen wrote, so they are stamped as such. An object already stamped is left as it is. The stamps are worked out in
33
+ * memory, where the plans that prove them read them as the objects' own, and written at the end. With `dryRun` nothing
34
+ * is written: the stamps stay in memory for the dry run's plans, and the record stays for the first real load. Never
35
+ * throws: a record that cannot be read names nothing and is deleted, and one whose stamps cannot be written is kept for
36
+ * the next try.
37
+ */
38
+ export async function migrateRootRecord(
39
+ resolver: Resolver,
40
+ conversionMap: TokenTypeConversionMap,
41
+ { dryRun = false }: { dryRun?: boolean } = {},
42
+ ): Promise<void> {
43
+ const unwritten = new Map<string, Stamp>();
44
+ const objects: Stamped[] = [];
45
+ const put: Put = (object, stamp) => {
46
+ if (!unwritten.has(object.id)) objects.push(object);
47
+ unwritten.set(object.id, { ...stamp, id: object.id });
48
+ };
49
+ setState({ unwrittenStamps: unwritten });
50
+ try {
51
+ let record: unknown;
52
+ try {
53
+ const payload = readSharded(RECORD_KEY);
54
+ if (!payload) return;
55
+ record = JSON.parse(payload);
56
+ } catch (_unreadable) {
57
+ record = undefined;
58
+ }
59
+ const variables = new Set<string>();
60
+ const styles = new Map<string, BaseStyle>();
61
+ for (const [token, entry] of Object.entries(isPlainObject(record) ? record : {})) {
62
+ if (!isPlainObject(entry)) continue;
63
+ const variable = typeof entry.variableId === 'string' ? await figma.variables.getVariableByIdAsync(entry.variableId) : null;
64
+ if (variable && !getStamp(variable)) {
65
+ const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
66
+ const hashes = isPlainObject(entry.lastSyncedHashes) ? entry.lastSyncedHashes : {};
67
+ const receipt: Record<string, string> = {};
68
+ for (const mode of collection?.modes ?? []) {
69
+ if (hashes[mode.name] !== undefined && variable.valuesByMode[mode.modeId] !== undefined) receipt[mode.modeId] = UNPROVEN;
70
+ }
71
+ if (Array.isArray(entry.lastSyncedScopes))
72
+ Object.assign(receipt, receiptOf({ scopes: reportedScopes(entry.lastSyncedScopes as VariableScope[]) }));
73
+ put(variable, { is: { token }, receipt });
74
+ variables.add(variable.id);
75
+ }
76
+ const style = typeof entry.styleId === 'string' ? await figma.getStyleByIdAsync(entry.styleId) : null;
77
+ if (style && !getStamp(style)) {
78
+ // Taken, for now, as holding what was written: `proveAgreeing` asks the style plan which fields it does.
79
+ put(style, { is: { token }, receipt: receiptOf(reportOf(style)) });
80
+ styles.set(token, style);
81
+ }
82
+ }
83
+ await proveAgreeing(resolver, conversionMap, variables, styles, put);
84
+ if (dryRun) return;
85
+ for (const object of objects) setStamp(object, unwritten.get(object.id)!);
86
+ clearSharded(RECORD_KEY);
87
+ } catch (_unwritable) {
88
+ // Figma refused a stamp: the record is kept, and the next load tries again. A dry run plans as if it named nothing.
89
+ unwritten.clear();
90
+ } finally {
91
+ if (!dryRun) setState({ unwrittenStamps: null });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Takes as written each field of these variables and styles that Figma holds the document's value in, and stamps
97
+ * every other `UNPROVEN`. A variable's modes start unproven, so its plan writes one only where Figma agrees with the
98
+ * document. A style starts as holding what was written, so its plan says, of one Figma agrees on, unchanged, and of
99
+ * any other, what it would write: the fields that differ from that are the unproven ones. A plan that stops, or a
100
+ * token it refuses, leaves every field unproven.
101
+ */
102
+ async function proveAgreeing(
103
+ resolver: Resolver,
104
+ conversionMap: TokenTypeConversionMap,
105
+ variables: Set<string>,
106
+ styles: Map<string, BaseStyle>,
107
+ put: Put,
108
+ ): Promise<void> {
109
+ const collectionName = figmaCollectionNameFor(resolver);
110
+ if (variables.size > 0) {
111
+ for (const op of (await new VariableSyncEngine().buildPlan(resolver, collectionName, conversionMap)).operations) {
112
+ if (op.kind !== 'SET_VARIABLE_VALUE' || !variables.has(op.variableId)) continue;
113
+ const variable = await figma.variables.getVariableByIdAsync(op.variableId);
114
+ const stamp = variable ? getStamp(variable) : undefined;
115
+ if (!variable || !stamp || stamp.receipt[op.modeId] !== UNPROVEN) continue;
116
+ put(variable, { ...stamp, receipt: { ...stamp.receipt, ...receiptOf({ [op.modeId]: variable.valuesByMode[op.modeId] }) } });
117
+ }
118
+ }
119
+ if (styles.size > 0) {
120
+ const operations = (await new StyleSyncEngine().buildPlan(resolver, conversionMap, collectionName)).operations;
121
+ for (const [token, style] of styles) {
122
+ const op = operations.find(
123
+ (o) => (o.kind === 'UPDATE_STYLE' && o.styleId === style.id) || (o.kind === 'SKIP' && o.reason === 'unchanged' && o.path === token),
124
+ );
125
+ // ponytail: a field is compared with what this machine would write only, so one that holds what a machine with
126
+ // the stack's first family wrote stays unproven here, beside a field edited by hand; the plan's own check takes both.
127
+ const wanted =
128
+ op?.kind === 'UPDATE_STYLE'
129
+ ? wantedOf(style.type as StyleKind, op.props as unknown as Record<string, unknown>, op.bindings ?? [])
130
+ : {};
131
+ const receipt: Record<string, string> = {};
132
+ for (const [field, value] of Object.entries(reportOf(style)))
133
+ receipt[field] = op && (!(field in wanted) || holds(value, wanted[field])) ? hashOf(value) : UNPROVEN;
134
+ put(style, { is: { token }, receipt });
135
+ }
136
+ }
137
+ }