@toghen/figma 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +18 -2
- package/cli/bin.mjs +19 -0
- package/cli/bridge.ts +243 -0
- package/cli/check.ts +62 -0
- package/cli/figcraft.ts +764 -0
- package/cli/figcraftApply.ts +1067 -0
- package/cli/figcraftFile.ts +476 -0
- package/cli/figcraftLibrary.ts +147 -0
- package/cli/figcraftRecord.ts +121 -0
- package/cli/figcraftScene.ts +661 -0
- package/cli/main.ts +218 -0
- package/cli/members.ts +65 -0
- package/cli/run.ts +756 -0
- package/component-files.md +665 -0
- package/dist/code.js +24161 -0
- package/dist/manifest.json +23 -0
- package/dist/ui.html +6918 -0
- package/guide.md +985 -0
- package/main/code.ts +50 -0
- package/main/components/apply.ts +704 -0
- package/main/components/endOfRun.ts +168 -0
- package/main/components/errors.ts +17 -0
- package/main/components/fieldWriter.ts +497 -0
- package/main/components/file.ts +263 -0
- package/main/components/receipt.ts +228 -0
- package/main/components/resolve.ts +1193 -0
- package/main/components/run.ts +273 -0
- package/main/components/snapshot.ts +341 -0
- package/main/components/stamp.ts +312 -0
- package/main/components/update.ts +465 -0
- package/main/dispatcher.ts +33 -0
- package/main/engines/batchRunner.ts +200 -0
- package/main/engines/ioAdapter.ts +132 -0
- package/main/engines/operationExecutor.ts +512 -0
- package/main/engines/propertyMapper.ts +221 -0
- package/main/engines/queryEngine.ts +45 -0
- package/main/engines/styleSyncEngine.ts +444 -0
- package/main/engines/variableSyncEngine.ts +561 -0
- package/main/handlers/auditSelection.ts +60 -0
- package/main/handlers/boundLayers.ts +51 -0
- package/main/handlers/cancelOperation.ts +18 -0
- package/main/handlers/executeApplyBindings.ts +69 -0
- package/main/handlers/executeScaffold.ts +59 -0
- package/main/handlers/executeStyleSync.ts +121 -0
- package/main/handlers/executeVariableSync.ts +82 -0
- package/main/handlers/exportDocument.ts +114 -0
- package/main/handlers/getSelection.ts +43 -0
- package/main/handlers/index.ts +30 -0
- package/main/handlers/inspectCorrespondence.ts +172 -0
- package/main/handlers/loadConventions.ts +86 -0
- package/main/handlers/loadDocument.ts +253 -0
- package/main/handlers/lock.ts +270 -0
- package/main/handlers/planApply.ts +157 -0
- package/main/handlers/planApplyBindings.ts +79 -0
- package/main/handlers/planScaffold.ts +84 -0
- package/main/handlers/planStyleSync.ts +49 -0
- package/main/handlers/planVariableSync.ts +49 -0
- package/main/handlers/readSelectionBindings.ts +160 -0
- package/main/handlers/resizeUi.ts +105 -0
- package/main/handlers/runComponent.ts +65 -0
- package/main/handlers/templates.ts +462 -0
- package/main/handlers/tokenGraph.ts +122 -0
- package/main/runtimeState.ts +66 -0
- package/main/utils/abortController.ts +11 -0
- package/main/utils/boundVariables.ts +84 -0
- package/main/utils/buildPayloads.ts +85 -0
- package/main/utils/correspondence.ts +658 -0
- package/main/utils/designSource.ts +122 -0
- package/main/utils/figmaCollectionIndex.ts +120 -0
- package/main/utils/figmaStyleAdapter.ts +268 -0
- package/main/utils/figmaTokenAdapter.ts +255 -0
- package/main/utils/flattenDesignSystem.ts +38 -0
- package/main/utils/folds.ts +203 -0
- package/main/utils/fontStack.ts +57 -0
- package/main/utils/lists.ts +4 -0
- package/main/utils/lockStore.ts +72 -0
- package/main/utils/moves.ts +67 -0
- package/main/utils/pageRoots.ts +58 -0
- package/main/utils/planSymbols.ts +96 -0
- package/main/utils/purpose.ts +351 -0
- package/main/utils/recentStore.ts +166 -0
- package/main/utils/resolverModes.ts +271 -0
- package/main/utils/rootRecord.ts +137 -0
- package/main/utils/shardedData.ts +142 -0
- package/main/utils/standing.ts +106 -0
- package/main/utils/styleTarget.ts +51 -0
- package/main/utils/sum.ts +612 -0
- package/main/utils/symbolicIdRegistry.ts +26 -0
- package/main/utils/syncState.ts +133 -0
- package/main/utils/templateStore.ts +73 -0
- package/main/utils/tokenPath.ts +28 -0
- package/main/utils/tokenStamps.ts +91 -0
- package/manifest.json +15 -0
- package/package.json +64 -4
- package/reference.md +1609 -0
- package/shared/components/artboard.ts +126 -0
- package/shared/components/axes.ts +420 -0
- package/shared/components/bindings.ts +64 -0
- package/shared/components/blueprint.ts +248 -0
- package/shared/components/check.ts +705 -0
- package/shared/components/diff.ts +668 -0
- package/shared/components/fields.ts +404 -0
- package/shared/components/groups.ts +306 -0
- package/shared/components/index.ts +140 -0
- package/shared/components/members.ts +280 -0
- package/shared/components/model.ts +273 -0
- package/shared/components/order.ts +53 -0
- package/shared/components/read.ts +480 -0
- package/shared/components/stack.ts +124 -0
- package/shared/components/steps.ts +186 -0
- package/shared/components/validate.ts +565 -0
- package/shared/conventions.ts +28 -0
- package/shared/inputs.ts +40 -0
- package/shared/operations.ts +233 -0
- package/shared/pendingNames.ts +39 -0
- package/shared/protocol.ts +588 -0
- package/shared/tokenTypeMap.ts +21 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a token stands between the document and Figma — the one comparison.
|
|
3
|
+
*
|
|
4
|
+
* ### Why this is not inside the sync engine
|
|
5
|
+
*
|
|
6
|
+
* Two readers ask the same question for different reasons. `VariableSyncEngine`
|
|
7
|
+
* asks it to decide **what to do**; the Inspector asks it to describe **what
|
|
8
|
+
* is**. Written twice, the two answers disagree eventually — and a screen
|
|
9
|
+
* reporting drift beside a plan reporting none is the exact shape of smell #22,
|
|
10
|
+
* one layer deeper than where it was found.
|
|
11
|
+
*
|
|
12
|
+
* So the comparison lives here, and both read it. What each does with the answer
|
|
13
|
+
* stays its own: the engine emits operations, the Inspector names a state.
|
|
14
|
+
*
|
|
15
|
+
* ### The two facts, and why they are named apart
|
|
16
|
+
*
|
|
17
|
+
* | Fact | From | Conclusive? |
|
|
18
|
+
* |---|---|---|
|
|
19
|
+
* | **agreement** — does Figma hold what the document asks for? | `toFigmaValue` + `figmaValuesEqual` | yes, wherever a variable exists |
|
|
20
|
+
* | **provenance** — has Figma moved since Toghen wrote it? | the variable's stamp | yes, wherever Toghen wrote |
|
|
21
|
+
*
|
|
22
|
+
* `drifted` is provenance: the stamp's receipt is a hash of what Figma reported
|
|
23
|
+
* right after the write, so a value Figma reports now that hashes otherwise was
|
|
24
|
+
* changed in Figma since. That holds whether or not the document moved too.
|
|
25
|
+
*
|
|
26
|
+
* Where Figma has not moved, it holds exactly what was written, so agreement
|
|
27
|
+
* says whether the document moved: the two comparisons a stamp is for
|
|
28
|
+
* (`tokenStamps.ts`).
|
|
29
|
+
*
|
|
30
|
+
* ### Several variables to a token
|
|
31
|
+
*
|
|
32
|
+
* With the sum (`sum.ts`) a token is one variable, or the links of a chain, each
|
|
33
|
+
* in a collection of its own with its own record. The comparison is per variable,
|
|
34
|
+
* and the token's condition is read across them.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { CorrespondenceState } from '../../shared/protocol.js';
|
|
38
|
+
import { hashOf } from '../components/stamp.js';
|
|
39
|
+
import { getIndexedVariableByCollectionAndName, type FigmaCollectionIndex } from './figmaCollectionIndex.js';
|
|
40
|
+
import { figmaValuesEqual, isAlias, toFigmaValue, type FigmaUnit } from './figmaTokenAdapter.js';
|
|
41
|
+
import { sameScopes } from './purpose.js';
|
|
42
|
+
import { flatMap } from './lists.js';
|
|
43
|
+
import type { CollectionStanding } from './standing.js';
|
|
44
|
+
import { closedBecause } from './standing.js';
|
|
45
|
+
import type { Sum, SumCell, SumToken, SumVariable, TokenRouting } from './sum.js';
|
|
46
|
+
import { referencePath } from '@toghen/lib';
|
|
47
|
+
import { tokenPathToVariableName } from './tokenPath.js';
|
|
48
|
+
import { reportedScopes } from './tokenStamps.js';
|
|
49
|
+
|
|
50
|
+
export type { TokenRouting };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which Figma variable backs one variable of the sum, and how that was decided.
|
|
54
|
+
*
|
|
55
|
+
* A token that is not routed to a variable at all — style-backed, unsupported,
|
|
56
|
+
* typed differently in different contexts, refused — has no variables to link,
|
|
57
|
+
* so it never gets here. A variable whose collection was built by an earlier
|
|
58
|
+
* sync gets `refuse` too. There is no fourth "not applicable" case, deliberately:
|
|
59
|
+
* with three the engine can narrow to `create | use` after one check, and a
|
|
60
|
+
* nullable fourth state would have to be handled at a point where it is already
|
|
61
|
+
* known to be impossible.
|
|
62
|
+
*/
|
|
63
|
+
export type VariableLink =
|
|
64
|
+
| { action: 'create'; symbolicId: string }
|
|
65
|
+
| { action: 'use'; variableId: string; rename?: string; adopted: boolean }
|
|
66
|
+
| { action: 'refuse'; reason: string };
|
|
67
|
+
|
|
68
|
+
export interface ModeCorrespondence {
|
|
69
|
+
mode: string;
|
|
70
|
+
/** The document declares a value for this variable in this mode. */
|
|
71
|
+
present: boolean;
|
|
72
|
+
/** The document's value, in Figma's representation, so both sides compare. */
|
|
73
|
+
documentValue: VariableValue | null;
|
|
74
|
+
/** What Figma holds. Null when there is no variable, or none for this mode. */
|
|
75
|
+
figmaValue: VariableValue | null;
|
|
76
|
+
/** Toghen wrote the mode: the variable's stamp is this token's and has a receipt for it, and Figma holds a value there. */
|
|
77
|
+
written: boolean;
|
|
78
|
+
/** Written, and Figma now reports something else than right after the write: changed in Figma since. */
|
|
79
|
+
drifted: boolean;
|
|
80
|
+
/** Null when there is nothing to compare against, which is not "differs". */
|
|
81
|
+
agrees: boolean | null;
|
|
82
|
+
/**
|
|
83
|
+
* Why the document's value here cannot be turned into a Figma value, when it cannot: a colour that is a keyword,
|
|
84
|
+
* a dimension that is two. A value lib stores as written (an untyped token, reached through a reference) can be
|
|
85
|
+
* anything, and Figma holds a colour, a number, a string or a boolean.
|
|
86
|
+
*/
|
|
87
|
+
unwritable?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A variable's scopes, the plan's and Figma's.
|
|
92
|
+
*
|
|
93
|
+
* Scopes follow the drift rule: Toghen records what it last wrote, and a scope list Figma holds that is not the
|
|
94
|
+
* record is a designer's. `write`: the plan's differ and nobody changed Figma's (or the variable has no record and
|
|
95
|
+
* holds what older Toghen wrote). `drift`: the plan's differ and a designer changed Figma's.
|
|
96
|
+
*/
|
|
97
|
+
export interface ScopeComparison {
|
|
98
|
+
/** What the plan wants, from the token's purpose. */
|
|
99
|
+
planned: VariableScope[];
|
|
100
|
+
/** What Figma holds. Absent for a variable that does not exist yet. */
|
|
101
|
+
held?: VariableScope[];
|
|
102
|
+
state: 'same' | 'write' | 'drift';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One Figma variable of a token: its single home, or one link of its chain. */
|
|
106
|
+
export interface VariableCorrespondence {
|
|
107
|
+
/** The record key: the token's lookup path, or a chain link's generated lookup path. */
|
|
108
|
+
key: string;
|
|
109
|
+
/** The Figma variable name. */
|
|
110
|
+
name: string;
|
|
111
|
+
/** The collection it lives in. */
|
|
112
|
+
collection: string;
|
|
113
|
+
link: VariableLink;
|
|
114
|
+
/** One entry per mode of its collection, the default context first. */
|
|
115
|
+
modes: ModeCorrespondence[];
|
|
116
|
+
/** Why a reference the document makes is written as a value instead of an alias. */
|
|
117
|
+
aliasWarnings: string[];
|
|
118
|
+
/** The web code syntax the variable should carry. */
|
|
119
|
+
codeSyntax: string;
|
|
120
|
+
/** Figma already holds that syntax on the variable. False for a variable that does not exist yet. */
|
|
121
|
+
codeSyntaxHeld: boolean;
|
|
122
|
+
scopes: ScopeComparison;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface TokenCorrespondence {
|
|
126
|
+
path: string;
|
|
127
|
+
/** The Figma variable name of the token (the head of a chain). */
|
|
128
|
+
variableName: string;
|
|
129
|
+
/** The document's `$type`, or null when the contexts disagree about it. */
|
|
130
|
+
type: string | null;
|
|
131
|
+
routing: TokenRouting;
|
|
132
|
+
/** The head first, then each earlier link of a chain. Empty unless the token routes to variables. */
|
|
133
|
+
variables: VariableCorrespondence[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { tokenPathToVariableName };
|
|
137
|
+
|
|
138
|
+
const VARIABLE_SYMBOL = '$var:';
|
|
139
|
+
|
|
140
|
+
/** A variable this plan creates, by its collection and name: two collections may hold one name. */
|
|
141
|
+
export function symbolicVariableId(collection: string, variableName: string): string {
|
|
142
|
+
return `${VARIABLE_SYMBOL}${collection}/${variableName}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The variable name a symbolic variable id stands for, or undefined for a real id. */
|
|
146
|
+
export function variableNameOfSymbol(id: string): string | undefined {
|
|
147
|
+
if (!id.startsWith(VARIABLE_SYMBOL)) return undefined;
|
|
148
|
+
const rest = id.slice(VARIABLE_SYMBOL.length);
|
|
149
|
+
return rest.slice(rest.indexOf('/') + 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface CompareTokenArgs {
|
|
153
|
+
token: SumToken;
|
|
154
|
+
sum: Sum;
|
|
155
|
+
standing: ReadonlyMap<string, CollectionStanding>;
|
|
156
|
+
/** The file's variables. An alias target is linked the way the target itself is, so a target renamed in Figma is still found by its stamp. */
|
|
157
|
+
figmaIndex: FigmaCollectionIndex;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compares one token with Figma, in every variable it is made of.
|
|
162
|
+
*
|
|
163
|
+
* Values are only converted for variable-routed tokens. `toFigmaValue` throws on
|
|
164
|
+
* a `shadow` or `typography` value by design — there is no variable shape for
|
|
165
|
+
* them — so reaching it for a style-routed token would turn a token the plugin
|
|
166
|
+
* handles perfectly well into a crash.
|
|
167
|
+
*/
|
|
168
|
+
export function compareToken(args: CompareTokenArgs): TokenCorrespondence {
|
|
169
|
+
const { token } = args;
|
|
170
|
+
const base = {
|
|
171
|
+
path: token.path,
|
|
172
|
+
variableName: tokenPathToVariableName(referencePath(token.path)),
|
|
173
|
+
type: token.type,
|
|
174
|
+
routing: token.routing,
|
|
175
|
+
};
|
|
176
|
+
if (token.routing.kind !== 'variable') return { ...base, variables: [] };
|
|
177
|
+
|
|
178
|
+
const variableType = token.routing.variableType;
|
|
179
|
+
const variables = token.variables.map((variable) => compareVariable(variable, token, variableType, args));
|
|
180
|
+
return { ...base, variables };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function compareVariable(
|
|
184
|
+
variable: SumVariable,
|
|
185
|
+
token: SumToken,
|
|
186
|
+
variableType: VariableResolvedDataType,
|
|
187
|
+
args: CompareTokenArgs,
|
|
188
|
+
): VariableCorrespondence {
|
|
189
|
+
const { figmaIndex } = args;
|
|
190
|
+
const standing = args.standing.get(variable.collection) as CollectionStanding;
|
|
191
|
+
const modeNames = standing.spec.modes;
|
|
192
|
+
const tokenType = token.type as string;
|
|
193
|
+
|
|
194
|
+
const base = {
|
|
195
|
+
key: variable.key,
|
|
196
|
+
name: variable.name,
|
|
197
|
+
collection: variable.collection,
|
|
198
|
+
codeSyntax: variable.codeSyntax,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// A number that is an opacity is written as the percentage Figma holds, and its hash says so.
|
|
202
|
+
const unit: FigmaUnit | undefined = token.purpose?.opacity ? 'percent' : undefined;
|
|
203
|
+
const planned = token.purpose?.scopes ?? ['ALL_SCOPES'];
|
|
204
|
+
|
|
205
|
+
const link = linkVariable({ variable, variableType, figmaIndex, standing });
|
|
206
|
+
if (link.action === 'refuse') {
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
link,
|
|
210
|
+
codeSyntaxHeld: true,
|
|
211
|
+
scopes: { planned, state: 'same' },
|
|
212
|
+
aliasWarnings: [],
|
|
213
|
+
modes: modeNames.map((mode) => unmeasured(mode, variable.cells.has(mode))),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const held = link.action === 'use' ? figmaIndex.variableById[link.variableId] : undefined;
|
|
218
|
+
const figmaValues = held?.valuesByMode;
|
|
219
|
+
// A receipt counts for the variable it is on, and only where Toghen found that variable by its stamp: one adopted by
|
|
220
|
+
// name holds no receipt of this token's, whatever its stamp says.
|
|
221
|
+
const receipt = link.action === 'use' && !link.adopted ? (held?.stamp?.receipt ?? {}) : {};
|
|
222
|
+
const aliasWarnings = new Set<string>();
|
|
223
|
+
|
|
224
|
+
const modes = modeNames.map((mode): ModeCorrespondence => {
|
|
225
|
+
const cell = variable.cells.get(mode);
|
|
226
|
+
if (!cell) return unmeasured(mode, false);
|
|
227
|
+
// The core refuses to compute the value for the type, and says why: the cell cannot be written, and the token is skipped whole.
|
|
228
|
+
if (cell.kind !== 'link' && cell.refused !== undefined) return { ...unmeasured(mode, false), unwritable: cell.refused };
|
|
229
|
+
|
|
230
|
+
const alias = aliasTarget(token.path, cell, variableType, args);
|
|
231
|
+
let aliased = alias && 'id' in alias ? alias : undefined;
|
|
232
|
+
const value = cell.kind === 'link' ? undefined : cell.value;
|
|
233
|
+
|
|
234
|
+
// An alias that would loop back into this variable is never written: the value is.
|
|
235
|
+
if (aliased && leadsBack(aliased.target, variable, link.action === 'use' ? link.variableId : undefined, variableType, args)) {
|
|
236
|
+
aliased = undefined;
|
|
237
|
+
if (value !== undefined) {
|
|
238
|
+
aliasWarnings.add(
|
|
239
|
+
`"${token.path}" refers to "${(cell as { to: string }).to}", which leads back to "${variable.name}" in Figma, so the value is written instead of an alias.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A reference that cannot be an alias and cannot be resolved has nothing to write, and says so
|
|
245
|
+
// in place of the warning that would have promised the value.
|
|
246
|
+
if (!aliased && value === undefined) {
|
|
247
|
+
aliasWarnings.add(
|
|
248
|
+
`"${token.path}" refers to "${(cell as { to: string }).to}", whose value cannot be read in ${variable.collection} / ${mode}, so nothing is written for it there.`,
|
|
249
|
+
);
|
|
250
|
+
return unmeasured(mode, false);
|
|
251
|
+
}
|
|
252
|
+
if (alias && 'warning' in alias) aliasWarnings.add(alias.warning);
|
|
253
|
+
|
|
254
|
+
let documentValue: VariableValue;
|
|
255
|
+
if (aliased) {
|
|
256
|
+
documentValue = { type: 'VARIABLE_ALIAS', id: aliased.id };
|
|
257
|
+
} else {
|
|
258
|
+
try {
|
|
259
|
+
documentValue = toFigmaValue(value, tokenType, unit);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { ...unmeasured(mode, false), unwritable: error instanceof Error ? error.message : String(error) };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const modeId = standing.modeId(mode);
|
|
266
|
+
const heldValue = figmaValues?.[modeId];
|
|
267
|
+
const figmaValue = heldValue === undefined ? null : heldValue;
|
|
268
|
+
// A mode Figma holds nothing in was never written there, whatever a receipt says.
|
|
269
|
+
const recorded = figmaValue === null ? undefined : receipt[modeId];
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
mode,
|
|
273
|
+
present: true,
|
|
274
|
+
documentValue,
|
|
275
|
+
figmaValue,
|
|
276
|
+
written: recorded !== undefined,
|
|
277
|
+
// Exact, not within a tolerance: the receipt hashes what Figma reported, float32 and all.
|
|
278
|
+
drifted: recorded !== undefined && hashOf(figmaValue) !== recorded,
|
|
279
|
+
agrees: figmaValue === null ? null : figmaValuesEqual(figmaValue, documentValue),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
...base,
|
|
285
|
+
link,
|
|
286
|
+
modes,
|
|
287
|
+
aliasWarnings: [...aliasWarnings],
|
|
288
|
+
codeSyntaxHeld: held?.codeSyntaxWeb === variable.codeSyntax,
|
|
289
|
+
scopes: compareScopes(
|
|
290
|
+
planned,
|
|
291
|
+
link,
|
|
292
|
+
held?.scopes,
|
|
293
|
+
receipt.scopes,
|
|
294
|
+
token.type === 'letterSpacing' ? ['LETTER_SPACING'] : ['ALL_SCOPES'],
|
|
295
|
+
),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether the plan's scopes may be written to the variable, by the drift rule. `recorded` is the receipt's hash of the
|
|
301
|
+
* scopes Figma reported after Toghen wrote them, from the variable's own stamp; a variable with none is judged by
|
|
302
|
+
* whether it still holds what older Toghen wrote to every variable (`ALL_SCOPES`, or `LETTER_SPACING` for a letter
|
|
303
|
+
* spacing): anything else is a designer's.
|
|
304
|
+
*/
|
|
305
|
+
function compareScopes(
|
|
306
|
+
planned: VariableScope[],
|
|
307
|
+
link: VariableLink,
|
|
308
|
+
held: readonly VariableScope[] | undefined,
|
|
309
|
+
recorded: string | undefined,
|
|
310
|
+
formerly: readonly VariableScope[],
|
|
311
|
+
): ScopeComparison {
|
|
312
|
+
if (link.action !== 'use' || held === undefined) return { planned, state: 'write' };
|
|
313
|
+
if (sameScopes(held, planned)) return { planned, held: [...held], state: 'same' };
|
|
314
|
+
const untouched = recorded !== undefined ? hashOf(reportedScopes(held)) === recorded : sameScopes(held, formerly);
|
|
315
|
+
return { planned, held: [...held], state: untouched ? 'write' : 'drift' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The variable a reference or a chain's fall-through is written as an alias to,
|
|
320
|
+
* or why it cannot be.
|
|
321
|
+
*
|
|
322
|
+
* The target is linked by `linkVariable`, the same call that decides the
|
|
323
|
+
* target's own row, so the id here is the one the target's `CREATE_VARIABLE`
|
|
324
|
+
* produces or the one it already has. Figma aliases only between variables of
|
|
325
|
+
* one type, so a target that routes elsewhere keeps the value. A reference to a
|
|
326
|
+
* token whose value is a chain aliases the head, the variable that carries the
|
|
327
|
+
* token's name.
|
|
328
|
+
*/
|
|
329
|
+
function aliasTarget(
|
|
330
|
+
owner: string,
|
|
331
|
+
cell: SumCell,
|
|
332
|
+
variableType: VariableResolvedDataType,
|
|
333
|
+
args: CompareTokenArgs,
|
|
334
|
+
): { id: string; target: SumVariable } | { warning: string } | undefined {
|
|
335
|
+
if (cell.kind === 'value') return undefined;
|
|
336
|
+
|
|
337
|
+
const refused = (why: string) => ({
|
|
338
|
+
warning: `"${owner}" refers to "${cell.to}", ${why}, so the value is written instead of an alias.`,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
let target: SumVariable | undefined;
|
|
342
|
+
if (cell.kind === 'link') {
|
|
343
|
+
// The next link of the same token: the same variable type by construction.
|
|
344
|
+
target = args.sum.variableByKey.get(cell.to);
|
|
345
|
+
if (!target) return { warning: `"${owner}" falls through to "${cell.to}", which is not in the plan.` };
|
|
346
|
+
} else {
|
|
347
|
+
const targetToken = args.sum.tokenByPath.get(cell.to);
|
|
348
|
+
if (targetToken?.routing.kind !== 'variable') return refused('which does not become a variable');
|
|
349
|
+
if (targetToken.routing.variableType !== variableType) {
|
|
350
|
+
return refused(`but they become different variable types (${variableType}, ${targetToken.routing.variableType})`);
|
|
351
|
+
}
|
|
352
|
+
// An alias hands over the raw number, so it joins variables of one unit only: a percentage to a percentage, a plain number to a plain number.
|
|
353
|
+
const ownerOpacity = args.sum.tokenByPath.get(owner)?.purpose?.opacity === true;
|
|
354
|
+
if (ownerOpacity !== (targetToken.purpose?.opacity === true)) {
|
|
355
|
+
return {
|
|
356
|
+
warning: `"${owner}" is ${ownerOpacity ? 'an opacity' : 'not an opacity'} and refers to "${cell.to}", which ${ownerOpacity ? 'is not one' : 'is one'}, so the value is written instead of an alias.`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
target = targetToken.variables[0];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const link = linkVariable({
|
|
363
|
+
variable: target,
|
|
364
|
+
variableType,
|
|
365
|
+
figmaIndex: args.figmaIndex,
|
|
366
|
+
standing: args.standing.get(target.collection) as CollectionStanding,
|
|
367
|
+
});
|
|
368
|
+
if (link.action === 'refuse') return refused('which cannot be synced');
|
|
369
|
+
|
|
370
|
+
return { id: link.action === 'create' ? link.symbolicId : link.variableId, target };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Whether an alias to `from` would lead back to `goal`, the variable it is written into (`goalId` is the one Figma
|
|
375
|
+
* holds for it, if any). Follows every mode of each variable: the aliases the plan writes (link and reference cells)
|
|
376
|
+
* and the ones Figma holds, since a mode the plan leaves alone keeps what it has. A held alias that the plan is
|
|
377
|
+
* about to replace counts too, which only ever writes a value where an alias would have done.
|
|
378
|
+
*/
|
|
379
|
+
function leadsBack(
|
|
380
|
+
from: SumVariable,
|
|
381
|
+
goal: SumVariable,
|
|
382
|
+
goalId: string | undefined,
|
|
383
|
+
variableType: VariableResolvedDataType,
|
|
384
|
+
args: CompareTokenArgs,
|
|
385
|
+
): boolean {
|
|
386
|
+
const seen = new Set<string>();
|
|
387
|
+
const held = (id: string): boolean => {
|
|
388
|
+
if (id === goalId) return true;
|
|
389
|
+
if (seen.has(id)) return false;
|
|
390
|
+
seen.add(id);
|
|
391
|
+
return Object.values(args.figmaIndex.variableById[id]?.valuesByMode ?? {}).some((value) => isAlias(value) && held(value.id));
|
|
392
|
+
};
|
|
393
|
+
const walk = (variable: SumVariable): boolean => {
|
|
394
|
+
if (variable.key === goal.key) return true;
|
|
395
|
+
if (seen.has(variable.key)) return false;
|
|
396
|
+
seen.add(variable.key);
|
|
397
|
+
for (const cell of variable.cells.values()) {
|
|
398
|
+
const next =
|
|
399
|
+
cell.kind === 'link'
|
|
400
|
+
? args.sum.variableByKey.get(cell.to)
|
|
401
|
+
: cell.kind === 'reference'
|
|
402
|
+
? args.sum.tokenByPath.get(cell.to)?.variables[0]
|
|
403
|
+
: undefined;
|
|
404
|
+
if (next && walk(next)) return true;
|
|
405
|
+
}
|
|
406
|
+
const link = linkVariable({
|
|
407
|
+
variable,
|
|
408
|
+
variableType,
|
|
409
|
+
figmaIndex: args.figmaIndex,
|
|
410
|
+
standing: args.standing.get(variable.collection) as CollectionStanding,
|
|
411
|
+
});
|
|
412
|
+
return link.action === 'use' && held(link.variableId);
|
|
413
|
+
};
|
|
414
|
+
return walk(from);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function unmeasured(mode: string, present: boolean): ModeCorrespondence {
|
|
418
|
+
return {
|
|
419
|
+
mode,
|
|
420
|
+
present,
|
|
421
|
+
documentValue: null,
|
|
422
|
+
figmaValue: null,
|
|
423
|
+
written: false,
|
|
424
|
+
drifted: false,
|
|
425
|
+
agrees: null,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ─── The engine's view: what to do ─── */
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Modes whose value has to be written.
|
|
433
|
+
*
|
|
434
|
+
* Note that this is **provenance first, then agreement**: where Figma has not
|
|
435
|
+
* moved since Toghen wrote, it holds what was written, and the mode is written
|
|
436
|
+
* when the document asks for something else. Where Figma has moved and does not
|
|
437
|
+
* hold the document's value, that is a hand edit: writing on agreement alone
|
|
438
|
+
* would silently discard it. With `overwriteDrift` the edit is taken back on
|
|
439
|
+
* purpose. A mode Toghen never wrote is written, and so is one Figma moved to
|
|
440
|
+
* the document's own value, which changes nothing visible and stamps it.
|
|
441
|
+
*/
|
|
442
|
+
export function staleModes(variable: VariableCorrespondence, overwriteDrift = false): Array<{ modeName: string; value: VariableValue }> {
|
|
443
|
+
return variable.modes
|
|
444
|
+
.filter((mode) => mode.present && (mode.written && !mode.drifted ? mode.agrees === false : overwriteDrift || !isDrift(mode)))
|
|
445
|
+
.map((mode) => ({ modeName: mode.mode, value: mode.documentValue as VariableValue }));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Modes Figma moved since Toghen wrote them, and that do not hold the document's value. */
|
|
449
|
+
export function driftedModes(variable: VariableCorrespondence): string[] {
|
|
450
|
+
return variable.modes.filter(isDrift).map((mode) => mode.mode);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const isDrift = (mode: ModeCorrespondence): boolean => mode.drifted && mode.agrees === false;
|
|
454
|
+
|
|
455
|
+
/** The cells of a token whose value Figma cannot hold: where (`collection / mode`) and why. */
|
|
456
|
+
export function unwritableCells(correspondence: TokenCorrespondence): Array<{ context: string; reason: string }> {
|
|
457
|
+
return flatMap(correspondence.variables, (variable) =>
|
|
458
|
+
variable.modes
|
|
459
|
+
.filter((mode) => mode.unwritable !== undefined)
|
|
460
|
+
.map((mode) => ({ context: `${variable.collection} / ${mode.mode}`, reason: mode.unwritable as string })),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/* ─── The Inspector's view: what is ─── */
|
|
465
|
+
|
|
466
|
+
export interface StateDescription {
|
|
467
|
+
state: CorrespondenceState;
|
|
468
|
+
/** Why, in words. Detail rides on the row it explains (R14). */
|
|
469
|
+
note?: string;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Names a token's condition, across every variable it is made of.
|
|
474
|
+
*
|
|
475
|
+
* Every phrase here describes something **observed**. None of them says what a
|
|
476
|
+
* sync would do — that is the plan's sentence to write, and two regions of one
|
|
477
|
+
* screen each predicting the future is how they come to disagree. Where the
|
|
478
|
+
* Inspector and a plan differ, they differ about tense, not about fact.
|
|
479
|
+
*/
|
|
480
|
+
export function describeState(correspondence: TokenCorrespondence): StateDescription {
|
|
481
|
+
const { routing, variables } = correspondence;
|
|
482
|
+
|
|
483
|
+
if (routing.kind === 'style') {
|
|
484
|
+
return { state: 'not_inspected', note: describeRouting(routing) };
|
|
485
|
+
}
|
|
486
|
+
if (routing.kind !== 'variable') {
|
|
487
|
+
return { state: 'blocked', note: describeRouting(routing) };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const refused = variables.find((variable) => variable.link.action === 'refuse');
|
|
491
|
+
if (refused && refused.link.action === 'refuse') return { state: 'blocked', note: refused.link.reason };
|
|
492
|
+
|
|
493
|
+
const unwritable = unwritableCells(correspondence);
|
|
494
|
+
if (unwritable.length > 0) {
|
|
495
|
+
return { state: 'blocked', note: unwritable.map((cell) => `${cell.context}: ${cell.reason}`).join(' ') };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const missing = variables.filter((variable) => variable.link.action === 'create');
|
|
499
|
+
if (missing.length === variables.length) {
|
|
500
|
+
return { state: 'absent', note: 'No Figma variable holds this token.' };
|
|
501
|
+
}
|
|
502
|
+
if (missing.length > 0) {
|
|
503
|
+
return {
|
|
504
|
+
state: 'outdated',
|
|
505
|
+
note: `The chain is incomplete: no Figma variable holds ${missing.map((variable) => `"${variable.name}"`).join(' or ')}.`,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const drifted = flatMap(variables, (variable) => driftedModes(variable).map((mode) => `${variable.collection}/${mode}`));
|
|
510
|
+
if (drifted.length > 0) {
|
|
511
|
+
return {
|
|
512
|
+
state: 'drifted',
|
|
513
|
+
note: `Figma holds a different value in ${listModes(drifted)} than Toghen wrote there, so the change was made here.`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const adopted = variables.find((variable) => variable.link.action === 'use' && variable.link.adopted);
|
|
518
|
+
if (adopted) {
|
|
519
|
+
return {
|
|
520
|
+
state: 'adopted',
|
|
521
|
+
note: `A variable named "${adopted.name}" already exists and was not written by Toghen. Nothing records what it held before.`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const modes = flatMap(variables, (variable) => variable.modes.map((mode) => ({ ...mode, mode: `${variable.collection}/${mode.mode}` })));
|
|
526
|
+
const unmeasuredModes = modes.filter((mode) => mode.present && mode.agrees === null).map((mode) => mode.mode);
|
|
527
|
+
if (unmeasuredModes.length > 0) {
|
|
528
|
+
return {
|
|
529
|
+
state: 'outdated',
|
|
530
|
+
note: `The variable exists but holds no value in ${listModes(unmeasuredModes)}.`,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const unstamped = modes.filter((mode) => mode.present && (!mode.written || mode.drifted));
|
|
535
|
+
const disagreeing = modes.filter((mode) => mode.agrees === false);
|
|
536
|
+
|
|
537
|
+
if (disagreeing.length > 0) {
|
|
538
|
+
return {
|
|
539
|
+
state: 'outdated',
|
|
540
|
+
note:
|
|
541
|
+
`The document has changed since this was last written, and Figma holds ` +
|
|
542
|
+
`something else in ${listModes(disagreeing.map((mode) => mode.mode))}.`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (unstamped.length > 0) {
|
|
547
|
+
// Both sides hold the same value; only the stamp does not say Toghen wrote
|
|
548
|
+
// it. Calling this `outdated` would report a difference that a direct
|
|
549
|
+
// comparison says is not there — and agreement is the fact this module can
|
|
550
|
+
// actually check (R11).
|
|
551
|
+
return {
|
|
552
|
+
state: 'current',
|
|
553
|
+
note:
|
|
554
|
+
`Figma matches the document. The variable's stamp does not record this ` +
|
|
555
|
+
`value, so a sync would write it again with no visible effect.`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return { state: 'current' };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Why a token's type or shape stops it reaching a Figma variable, in words. */
|
|
563
|
+
export function describeRouting(routing: TokenRouting): string {
|
|
564
|
+
switch (routing.kind) {
|
|
565
|
+
case 'style':
|
|
566
|
+
return 'Backed by a Figma style. Styles are not compared yet, so this row reports nothing about it either way.';
|
|
567
|
+
case 'type-conflict':
|
|
568
|
+
return (
|
|
569
|
+
`The document gives this token different types in different contexts ` +
|
|
570
|
+
`(${routing.types.join(', ')}). A Figma variable has one type.`
|
|
571
|
+
);
|
|
572
|
+
case 'unsupported':
|
|
573
|
+
return `No Figma variable can hold a token of type "${routing.tokenType ?? 'unknown'}".`;
|
|
574
|
+
case 'missing-route':
|
|
575
|
+
return `Type "${routing.tokenType}" is routed to a variable with no variable type to use.`;
|
|
576
|
+
case 'refused':
|
|
577
|
+
return routing.reason;
|
|
578
|
+
case 'variable':
|
|
579
|
+
return '';
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function listModes(modes: readonly string[]): string {
|
|
584
|
+
return modes.length === 1 ? `${modes[0]}` : modes.join(' and ');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* ─── Linking ─── */
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Decides which Figma variable backs a variable of the sum.
|
|
591
|
+
*
|
|
592
|
+
* Ordering matters. A type mismatch has to be caught *before* falling through to
|
|
593
|
+
* "create", because Figma variables cannot change `resolvedType` and the name is
|
|
594
|
+
* already taken — the old code planned a `CREATE_VARIABLE` that Figma rejects,
|
|
595
|
+
* which lost the symbolic id and cascaded into every dependent operation
|
|
596
|
+
* failing.
|
|
597
|
+
*
|
|
598
|
+
* A variable is found by its stamp, and only inside the collection the variable
|
|
599
|
+
* belongs to: the sum never reaches into a collection an earlier sync built, and
|
|
600
|
+
* the variable a token had before it moved collection is `moves.ts`'s. A name
|
|
601
|
+
* finds one only to adopt it.
|
|
602
|
+
*/
|
|
603
|
+
function linkVariable(args: {
|
|
604
|
+
variable: SumVariable;
|
|
605
|
+
variableType: VariableResolvedDataType;
|
|
606
|
+
figmaIndex: FigmaCollectionIndex;
|
|
607
|
+
standing: CollectionStanding;
|
|
608
|
+
}): VariableLink {
|
|
609
|
+
const { variable, variableType, figmaIndex, standing } = args;
|
|
610
|
+
const { key: path, name: variableName } = variable;
|
|
611
|
+
|
|
612
|
+
const closed = closedBecause(standing);
|
|
613
|
+
if (closed) return { action: 'refuse', reason: closed.message };
|
|
614
|
+
|
|
615
|
+
const cached = figmaIndex.variablesByToken[path]?.find((stamped) => stamped.variableCollectionId === standing.existing?.id);
|
|
616
|
+
if (cached) {
|
|
617
|
+
if (cached.resolvedType !== variableType) {
|
|
618
|
+
return {
|
|
619
|
+
action: 'refuse',
|
|
620
|
+
reason:
|
|
621
|
+
`"${path}" is ${variableType} in the document but the variable it is ` +
|
|
622
|
+
`linked to is ${cached.resolvedType}. A Figma variable cannot change type — ` +
|
|
623
|
+
`delete "${cached.name}" in Figma to have it recreated.`,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// A manual rename in Figma does not change the stamp, so the variable is
|
|
627
|
+
// still found. Without this the drift is invisible: the plan reports
|
|
628
|
+
// "unchanged" and the file quietly diverges from the document.
|
|
629
|
+
return {
|
|
630
|
+
action: 'use',
|
|
631
|
+
variableId: cached.id,
|
|
632
|
+
rename: cached.name === variableName ? undefined : variableName,
|
|
633
|
+
adopted: false,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (standing.existing) {
|
|
638
|
+
const byName = getIndexedVariableByCollectionAndName(figmaIndex, standing.existing.id, variableName);
|
|
639
|
+
if (byName) {
|
|
640
|
+
if (byName.resolvedType !== variableType) {
|
|
641
|
+
return {
|
|
642
|
+
action: 'refuse',
|
|
643
|
+
reason:
|
|
644
|
+
`"${path}" is ${variableType} in the document but a ${byName.resolvedType} ` +
|
|
645
|
+
`variable named "${variableName}" already exists. Figma cannot change a ` +
|
|
646
|
+
`variable's type — delete it to have it recreated.`,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
// Found by name with no stamp of this token's: this variable was not
|
|
650
|
+
// written by Toghen for it, and syncing writes into somebody else's
|
|
651
|
+
// object. A receipt on it, if any, is another token's and is not read.
|
|
652
|
+
// Worth naming apart from a plain link — see `adopted` in describeState.
|
|
653
|
+
return { action: 'use', variableId: byName.id, adopted: true };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return { action: 'create', symbolicId: symbolicVariableId(variable.collection, variableName) };
|
|
658
|
+
}
|