@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,497 @@
1
+ /**
2
+ * The one place a field that carries a token is written, and read back.
3
+ *
4
+ * The rule, as `specs.md` says it once: a token goes on any field; it is bound where Figma can bind it (the field
5
+ * table's `bind`); it is written as its resolved value where Figma cannot; and a written value follows one context
6
+ * only, the declared defaults, said once per run and field in the report. A literal is written as given.
7
+ */
8
+
9
+ import { FIELDS, GRID_FIELDS, PIXELS_ONLY_FIELDS, namesStyle } from '../../shared/components/fields.js';
10
+ import type { Layer, LayoutGridRecord, Value } from '../../shared/components/model.js';
11
+ import type { Step } from '../../shared/components/steps.js';
12
+ import { boundIdOf } from '../utils/boundVariables.js';
13
+ import type { Build } from './apply.js';
14
+ import type { Composite, Resolved } from './resolve.js';
15
+
16
+ /** What writing a field came to. */
17
+ export type FieldWrite =
18
+ | { how: 'bound'; variable: Variable }
19
+ /** A token on a field, or part of one, Figma cannot bind: its value, in this context. */
20
+ | { how: 'value'; context: string }
21
+ /** A style made from the token, applied. */
22
+ | { how: 'style' }
23
+ /** A literal. */
24
+ | { how: 'plain' };
25
+
26
+ /** A number as Figma keeps it: float32. */
27
+ export const f32 = (value: unknown): unknown => (typeof value === 'number' ? Math.fround(value) : (value ?? null));
28
+
29
+ /** A colour token's alpha at the declared defaults, from the core; 1 when the core gives none. */
30
+ export function alphaOf(resolved: Resolved, path: string): number {
31
+ return (resolved.valueOf(path)?.value as RGBA | undefined)?.a ?? 1;
32
+ }
33
+
34
+ function hexPaint(hex: string): SolidPaint {
35
+ const channel = (at: number): number => parseInt(hex.slice(at, at + 2), 16) / 255;
36
+ // A paint's colour carries no alpha: opacity is the paint's own.
37
+ return { type: 'SOLID', color: { r: channel(1), g: channel(3), b: channel(5) }, opacity: hex.length === 9 ? channel(7) : 1 };
38
+ }
39
+
40
+ /** Says, once per run and field, that a value was written and will not follow its token. */
41
+ /**
42
+ * The report's line for a field of a layer that is written, wholly or in part, as a token's value: once per run, field
43
+ * and layer, in a dry run as in a real one. Undefined when nothing of it is.
44
+ */
45
+ export function valueLine(component: string, resolved: Resolved, layer: Layer, field: string): string | undefined {
46
+ const spec = FIELDS[field];
47
+ const value = layer.fields[field];
48
+ if (!spec?.token || value === undefined) return undefined;
49
+ let what: string | undefined;
50
+ let path = '';
51
+ let context: string | undefined;
52
+ // The root layer is the component itself, and is named once.
53
+ const where = layer.address === component ? layer.address : `${component}/${layer.address}`;
54
+ if (spec.token.bind?.call === 'text' && (value as Value).kind === 'token') {
55
+ // A line height or letter spacing that is not in pixels is written as its value; a font stack binds its first family.
56
+ path = (value as { path: string }).path;
57
+ const written = resolved.textValue(field, path);
58
+ if (written) [what, context] = ['the token', written.context];
59
+ const stack = field === 'fontFamily' ? resolved.stack(path) : undefined;
60
+ if (stack) {
61
+ const rest = stack
62
+ .slice(1)
63
+ .map((family) => `"${family}"`)
64
+ .join(', ');
65
+ return `"${field}" of "${where}": {${path}} is a font stack, and its variable holds the first family, "${stack[0]}"; ${rest} ${stack.length === 2 ? 'is' : 'are'} not used`;
66
+ }
67
+ } else if (spec.token.bind?.call === 'paint') {
68
+ const translucent = (value as Value[]).find((paint) => paint.kind === 'token' && alphaOf(resolved, paint.path) < 1);
69
+ if (translucent?.kind === 'token') [what, path] = ["the colour's alpha", translucent.path];
70
+ } else if (spec.value === 'dashes') {
71
+ // A dash pattern is a list, which holds a token only alone.
72
+ const [dash] = value as Value[];
73
+ if (dash?.kind === 'token') [what, path] = ['the token', dash.path];
74
+ } else if (!spec.token.bind && spec.value !== 'token' && (value as Value).kind === 'token') {
75
+ [what, path] = ['the token', (value as { path: string }).path];
76
+ }
77
+ // Unit 4: a gradient or a shadow whose parts Figma cannot bind, and a layout grid's colour.
78
+ if (what === undefined && (spec.value === 'paints' || spec.value === 'effects')) {
79
+ for (const part of value as Value[]) {
80
+ const composite = part.kind === 'token' ? resolved.composite(part.path) : undefined;
81
+ if (!composite || composite.written.length === 0) continue;
82
+ [what, context] = [
83
+ `part of the ${composite.paint ? 'gradient' : 'shadow'} "{${(part as { path: string }).path}}" (${composite.written.join(', ')})`,
84
+ composite.context,
85
+ ];
86
+ break;
87
+ }
88
+ } else if (what === undefined && spec.value === 'grids') {
89
+ const colour = (value as LayoutGridRecord[]).find((grid) => grid.color?.kind === 'token')?.color;
90
+ if (colour?.kind === 'token') [what, path] = ["a grid's colour", colour.path];
91
+ }
92
+ if (what === undefined) return undefined;
93
+ return `"${field}" of "${where}": ${what} is written as its value in "${context ?? resolved.valueOf(path)!.context}"; it will not follow the token until the next run`;
94
+ }
95
+
96
+ /** The lines a dry run's steps would say as they write: every field of a layer they create, and the fields they patch. */
97
+ export function plannedValueLines(component: string, resolved: Resolved, steps: Step[]): string[] {
98
+ const lines = new Set<string>();
99
+ const add = (layer: Layer, fields: string[], deep: boolean): void => {
100
+ for (const field of fields) {
101
+ const line = valueLine(component, resolved, layer, field);
102
+ if (line) lines.add(line);
103
+ }
104
+ if (deep) for (const child of layer.children) add(child, Object.keys(child.fields), true);
105
+ };
106
+ for (const step of steps) {
107
+ if (step.kind === 'CREATE_SET') for (const member of step.members) add(member.tree, Object.keys(member.tree.fields), true);
108
+ else if (step.kind === 'CREATE_ARTBOARD' || step.kind === 'CREATE_MEMBER')
109
+ add(step.member.tree, Object.keys(step.member.tree.fields), true);
110
+ else if (step.kind === 'CREATE_LAYER' || step.kind === 'REPLACE_LAYER') add(step.layer, Object.keys(step.layer.fields), false);
111
+ else if (step.kind === 'PATCH_LAYER') add(step.layer, step.fields, false);
112
+ }
113
+ return [...lines];
114
+ }
115
+
116
+ const STYLES: Record<string, (node: SceneNode, build: Build, path: string) => Promise<void>> = {
117
+ textStyle: (node, build, path) => (node as TextNode).setTextStyleIdAsync(build.resolved.textStyle(path).id),
118
+ effectStyle: (node, build, path) => (node as BlendMixin).setEffectStyleIdAsync(build.resolved.effectStyle(path).id),
119
+ };
120
+
121
+ /** Writes one field of a layer, as the field table says a token reaches it. */
122
+ export async function writeField(build: Build, node: SceneNode, layer: Layer, field: string): Promise<FieldWrite> {
123
+ const spec = FIELDS[field];
124
+ const target = node as unknown as Record<string, unknown>;
125
+ const line = valueLine(build.spec.name, build.resolved, layer, field);
126
+ if (line && !build.warnings.includes(line)) build.warnings.push(line);
127
+
128
+ if (spec.token?.bind?.call === 'paint') {
129
+ let written: FieldWrite = { how: 'plain' };
130
+ target[field] = (layer.fields[field] as Value[]).map((paint) => {
131
+ if (paint.kind !== 'token') return hexPaint(String((paint as { value: string }).value));
132
+ const composite = build.resolved.composite(paint.path);
133
+ if (composite?.paint) {
134
+ written = compositeWrite(build.resolved, composite, written);
135
+ return gradientPaint(build.resolved, composite);
136
+ }
137
+ const variable = build.resolved.color(paint.path);
138
+ // Measured: a paint bound again on an existing node keeps the colour it was given, and Figma draws that, so it is given the variable's.
139
+ const { r, g, b } = variable.resolveForConsumer(node).value as RGBA;
140
+ // Measured: a paint's opacity does not bind (§14), so a colour's alpha is written as the paint's opacity, at the defaults.
141
+ const alpha = alphaOf(build.resolved, paint.path);
142
+ const solid: SolidPaint = alpha < 1 ? { type: 'SOLID', color: { r, g, b }, opacity: alpha } : { type: 'SOLID', color: { r, g, b } };
143
+ if (alpha < 1) written = { how: 'value', context: build.resolved.valueOf(paint.path)!.context };
144
+ else if (written.how === 'plain') written = { how: 'bound', variable };
145
+ // Measured: setBoundVariableForPaint returns a new paint; the fills are the returned copy.
146
+ return figma.variables.setBoundVariableForPaint(solid, 'color', variable);
147
+ });
148
+ return written;
149
+ }
150
+
151
+ if (spec.value === 'effects') {
152
+ let written: FieldWrite = { how: 'plain' };
153
+ const bind: Bind<Effect> = (effect, part, variable) =>
154
+ figma.variables.setBoundVariableForEffect(effect, part as VariableBindableEffectField, variable);
155
+ const effects: Effect[] = [];
156
+ for (const effect of layer.fields.effects as Value[]) {
157
+ if (effect.kind !== 'token') {
158
+ effects.push(literalBlur(Number((effect as { value: number }).value)));
159
+ continue;
160
+ }
161
+ const composite = build.resolved.composite(effect.path)!;
162
+ written = compositeWrite(build.resolved, composite, written);
163
+ effects.push(...effectsOf(build.resolved, composite, bind));
164
+ }
165
+ target.effects = effects;
166
+ return written;
167
+ }
168
+ if (spec.value === 'grids') {
169
+ const records = layer.fields.layoutGrids as LayoutGridRecord[];
170
+ const bind: Bind<LayoutGrid> = (grid, part, variable) =>
171
+ figma.variables.setBoundVariableForLayoutGrid(grid, part as VariableBindableLayoutGridField, variable);
172
+ // A bound field starts at the number its variable gives this node, as a paint starts at its colour.
173
+ target.layoutGrids = records.map((record) =>
174
+ layoutGridOf(build.resolved, record, bind, (variable) => variable.resolveForConsumer(node).value as number),
175
+ );
176
+ const colour = records.find((record) => record.color?.kind === 'token')?.color;
177
+ if (colour?.kind === 'token') return { how: 'value', context: build.resolved.valueOf(colour.path)!.context };
178
+ for (const record of records) {
179
+ for (const field of GRID_FIELDS[record.pattern === 'GRID' ? 'GRID' : record.alignment!]) {
180
+ const part = record[field as 'count'];
181
+ if (part?.kind === 'token') return { how: 'bound', variable: build.resolved.float(part.path) };
182
+ }
183
+ }
184
+ return { how: 'plain' };
185
+ }
186
+ if (spec.value === 'dashes') {
187
+ // Measured: not bindable (`Unknown field`). A strokeStyle token is written as its dash array, at the defaults.
188
+ const dashes = layer.fields[field] as Value[];
189
+ if (dashes[0]?.kind === 'token') {
190
+ const { value: resolved, context } = build.resolved.valueOf(dashes[0].path)!;
191
+ target[field] = resolved;
192
+ return { how: 'value', context };
193
+ }
194
+ target[field] = dashes.map((dash) => (dash as { value: number }).value);
195
+ return { how: 'plain' };
196
+ }
197
+
198
+ const value = layer.fields[field] as Value;
199
+ if (value.kind === 'token' && namesStyle(spec)) {
200
+ await STYLES[field](node, build, value.path);
201
+ return { how: 'style' };
202
+ }
203
+ if (spec.token?.bind?.call === 'text') return writeTextField(build, node as TextNode, field, value);
204
+ if (value.kind === 'token' && spec.token?.bind) {
205
+ const variable = build.resolved.variable(value.path);
206
+ (node as unknown as { setBoundVariable(f: VariableBindableNodeField, v: Variable): void }).setBoundVariable(
207
+ spec.token.bind.field as VariableBindableNodeField,
208
+ variable,
209
+ );
210
+ return { how: 'bound', variable };
211
+ }
212
+ if (value.kind === 'token') {
213
+ // A field Figma cannot bind: resolve() refused the run unless the core gave the value.
214
+ const { value: resolved, context } = build.resolved.valueOf(value.path)!;
215
+ target[field] = resolved;
216
+ return { how: 'value', context };
217
+ }
218
+ if (value.kind === 'literal') target[field] = value.value;
219
+ else if (value.kind === 'plain' && value.value === null) target[field] = null;
220
+ return { how: 'plain' };
221
+ }
222
+
223
+ /**
224
+ * A text field: bound on the whole text, since Figma binds a text field per range (`setRangeBoundVariable`); a line
225
+ * height or letter spacing not in pixels written as its value instead, and a literal one taken as pixels.
226
+ */
227
+ function writeTextField(build: Build, text: TextNode, field: string, value: Value): FieldWrite {
228
+ const target = text as unknown as Record<string, unknown>;
229
+ if (value.kind === 'token') {
230
+ const written = build.resolved.textValue(field, value.path);
231
+ if (written) {
232
+ target[field] = written.value;
233
+ return { how: 'value', context: written.context };
234
+ }
235
+ const variable = build.resolved.variable(value.path);
236
+ // unmeasured: what an empty text takes; a range needs at least one character
237
+ text.setRangeBoundVariable(0, text.characters.length, field as VariableBindableTextField, variable);
238
+ return { how: 'bound', variable };
239
+ }
240
+ if (value.kind === 'literal') target[field] = PIXELS_ONLY_FIELDS.includes(field) ? { unit: 'PIXELS', value: value.value } : value.value;
241
+ return { how: 'plain' };
242
+ }
243
+
244
+ /** A text field's value as Figma keeps it: a number, and the number in a line height or letter spacing, as float32. */
245
+ function keptText(value: unknown): unknown {
246
+ if (typeof value === 'object' && value !== null && typeof (value as { value?: unknown }).value === 'number') {
247
+ return { ...(value as object), value: f32((value as { value: number }).value) };
248
+ }
249
+ return f32(value);
250
+ }
251
+
252
+ /**
253
+ * What Figma reports for a paint: a bound paint as its variable only (ruling 8a). Its opacity is left out: the colour's
254
+ * alpha is the variable's, which a target paints in the layer's mode (FigCraft) or Toghen writes at the defaults
255
+ * (Figma), so it is not something the file says. A receipt that held the opacity is taken again, with no write.
256
+ */
257
+ export function reportedPaint(paint: Paint): unknown {
258
+ const bound = (paint as SolidPaint).boundVariables?.color;
259
+ if (bound) return { variable: bound.id };
260
+ if (paint.type.startsWith('GRADIENT_')) return reportedGradient(paint as GradientPaint);
261
+ if (paint.type !== 'SOLID') return { type: paint.type };
262
+ return { color: [paint.color.r, paint.color.g, paint.color.b].map(f32), opacity: f32(paint.opacity ?? 1) };
263
+ }
264
+
265
+ /**
266
+ * What Figma reports now for one field of a node, as the receipt compares it: a paint per paint, a style by its id,
267
+ * and a field bound by its parts (`cornerRadius`, `strokeWeight`) as bound when every part names one variable.
268
+ * Parts bound apart read as `{ variable: null }`, which the file's value never leaves: a hand edit.
269
+ */
270
+ export function reportField(node: SceneNode, field: string): unknown {
271
+ const read = node as unknown as Record<string, unknown>;
272
+ const spec = FIELDS[field];
273
+ if (spec?.token?.bind?.call === 'paint') {
274
+ const paints = read[field];
275
+ return Array.isArray(paints) ? paints.map(reportedPaint) : null;
276
+ }
277
+ if (spec?.value === 'effects' || spec?.value === 'grids') {
278
+ const parts = read[field];
279
+ return Array.isArray(parts) ? parts.map(spec.value === 'effects' ? reportedEffect : reportedGrid) : null;
280
+ }
281
+ if (namesStyle(spec)) return read[`${field}Id`];
282
+ if (spec?.value === 'dashes') return (read[field] as number[]).map(f32);
283
+ const bound = boundIdOf((node as unknown as { boundVariables?: Record<string, unknown> }).boundVariables ?? {}, field);
284
+ if (bound !== undefined) return { variable: bound };
285
+ if (spec?.token?.bind?.call === 'text') {
286
+ // A font's halves have no getter of their own: they are the halves of `fontName`.
287
+ const font = (node as TextNode).fontName as FontName;
288
+ return keptText(field === 'fontFamily' ? font.family : field === 'fontStyle' ? font.style : read[field]);
289
+ }
290
+ return f32(read[field]);
291
+ }
292
+
293
+ /** What the file's value of a text field would leave in the receipt, as `reportField` reads it: undefined for a field that is not one. */
294
+ export function expectedText(resolved: Resolved, field: string, value: Value): unknown {
295
+ if (FIELDS[field]?.token?.bind?.call !== 'text') return undefined;
296
+ if (value.kind === 'token') {
297
+ const written = resolved.textValue(field, value.path);
298
+ return written ? keptText(written.value) : { variable: resolved.variable(value.path).id };
299
+ }
300
+ const given = (value as { value: unknown }).value;
301
+ return keptText(PIXELS_ONLY_FIELDS.includes(field) ? { unit: 'PIXELS', value: given } : given);
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Gradients, effects and layout grids (unit 4)
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /** Binds one part of a Figma object: the Plugin API's helper when writing, `withAlias` for the receipt's expected side. */
309
+ export type Bind<T> = (target: T, part: string, variable: Variable) => T;
310
+
311
+ const aliasOf = (variable: Variable): VariableAlias => ({ type: 'VARIABLE_ALIAS', id: variable.id });
312
+
313
+ /** What the binding helpers return: a copy of the object with the alias in its `boundVariables`. */
314
+ export const withAlias = <T>(target: T, part: string, variable: Variable): T =>
315
+ ({ ...target, boundVariables: { ...(target as { boundVariables?: object }).boundVariables, [part]: aliasOf(variable) } }) as T;
316
+
317
+ /** What writing a composite token comes to, after what the field's earlier parts came to: written as a value wins, then bound. */
318
+ function compositeWrite(resolved: Resolved, composite: Composite, before: FieldWrite): FieldWrite {
319
+ if (composite.written.length > 0) return { how: 'value', context: composite.context };
320
+ const [part, path] = Object.entries(composite.bound)[0] ?? [];
321
+ if (before.how !== 'plain' || path === undefined) return before;
322
+ return { how: 'bound', variable: part.endsWith('.color') ? resolved.color(path) : resolved.float(path) };
323
+ }
324
+
325
+ /**
326
+ * A gradient token's paint, each stop whose colour follows a token bound by writing the alias into the stop: the
327
+ * helper refuses a gradient, and the alias in the stop reads back bound (measured §14). Positions and the transform are
328
+ * the core's at the declared defaults.
329
+ */
330
+ export function gradientPaint(resolved: Resolved, composite: Composite): GradientPaint {
331
+ const paint = composite.paint!;
332
+ const gradientStops = paint.gradientStops.map((stop, index) => {
333
+ const path = composite.bound[`${index}.color`];
334
+ return path === undefined ? stop : { ...stop, boundVariables: { color: aliasOf(resolved.color(path)) } };
335
+ });
336
+ return { ...paint, gradientStops };
337
+ }
338
+
339
+ /** A shadow's or a blur's effects, each part that follows a token bound with `bind`. */
340
+ export function effectsOf(resolved: Resolved, composite: Composite, bind: Bind<Effect>): Effect[] {
341
+ return composite.effects!.map((effect, index) => {
342
+ let out = effect;
343
+ for (const [part, path] of Object.entries(composite.bound)) {
344
+ const [at, name] = part.split('.');
345
+ if (Number(at) === index) out = bind(out, name, name === 'color' ? resolved.color(path) : resolved.float(path));
346
+ }
347
+ return out;
348
+ });
349
+ }
350
+
351
+ /** A `(literal)` radius on `effects`: a layer blur of that many pixels. */
352
+ export const literalBlur = (radius: number): Effect => ({ type: 'LAYER_BLUR', blurType: 'NORMAL', radius, visible: true });
353
+
354
+ /** The colour a grid that names none is given. // unmeasured: that it is Figma's own for a new grid, red at 10% */
355
+ const GRID_COLOUR: RGBA = { r: 1, g: 0, b: 0, a: 0.1 };
356
+
357
+ /** A grid's colour, which Figma cannot bind: a token's value at the declared defaults, or a `(literal)` `#rrggbb[aa]`. */
358
+ function gridColour(resolved: Resolved, value: Value | undefined): RGBA {
359
+ if (value === undefined) return GRID_COLOUR;
360
+ if (value.kind === 'token') return resolved.valueOf(value.path)!.value as RGBA;
361
+ const { color, opacity } = hexPaint(String((value as { value: string }).value));
362
+ return { ...color, a: opacity ?? 1 };
363
+ }
364
+
365
+ /**
366
+ * One grid of `layoutGrids`: the fields its alignment takes (`GRID_FIELDS`), each token bound with `bind`, starting at
367
+ * the number `numberOf` gives; a literal or a plain count as given; an offset the file leaves out, 0.
368
+ */
369
+ export function layoutGridOf(
370
+ resolved: Resolved,
371
+ record: LayoutGridRecord,
372
+ bind: Bind<LayoutGrid>,
373
+ numberOf: (variable: Variable) => number,
374
+ ): LayoutGrid {
375
+ const grid: Record<string, unknown> = { pattern: record.pattern };
376
+ if (record.pattern !== 'GRID') grid.alignment = record.alignment;
377
+ const bound: [string, Variable][] = [];
378
+ for (const field of GRID_FIELDS[record.pattern === 'GRID' ? 'GRID' : record.alignment!]) {
379
+ const value = record[field as 'count'];
380
+ if (value?.kind === 'token') {
381
+ const variable = resolved.float(value.path);
382
+ bound.push([field, variable]);
383
+ grid[field] = numberOf(variable);
384
+ } else grid[field] = value === undefined ? 0 : (value as { value: number }).value;
385
+ }
386
+ grid.visible = true;
387
+ grid.color = gridColour(resolved, record.color);
388
+ return bound.reduce((out, [field, variable]) => bind(out, field, variable), grid as unknown as LayoutGrid);
389
+ }
390
+
391
+ const rgbaOf = (color: RGBA): unknown[] => [color.r, color.g, color.b, color.a].map(f32);
392
+
393
+ /** A part as the receipt reads it: its variable when bound, else its number. */
394
+ const partOf = (bound: Record<string, VariableAlias | undefined> | undefined, part: string, value: unknown): unknown =>
395
+ bound?.[part] ? { variable: bound[part]!.id } : f32(value);
396
+
397
+ function reportedGradient(paint: GradientPaint): unknown {
398
+ const stops = paint.gradientStops.map((stop) => ({
399
+ position: f32(stop.position),
400
+ color: stop.boundVariables?.color ? { variable: stop.boundVariables.color.id } : rgbaOf(stop.color),
401
+ }));
402
+ return { type: paint.type, stops, transform: [...paint.gradientTransform[0], ...paint.gradientTransform[1]].map(f32) };
403
+ }
404
+
405
+ /** What Figma reports for an effect: each of a shadow's fields, a blur's radius, by its variable when bound. */
406
+ export function reportedEffect(effect: Effect): unknown {
407
+ const bound = (effect as { boundVariables?: Record<string, VariableAlias> }).boundVariables;
408
+ if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
409
+ return {
410
+ type: effect.type,
411
+ // Measured: binding the colour replaces its alpha with the variable's, so a bound colour reads as its variable alone.
412
+ color: bound?.color ? { variable: bound.color.id } : rgbaOf(effect.color),
413
+ offsetX: partOf(bound, 'offsetX', effect.offset.x),
414
+ offsetY: partOf(bound, 'offsetY', effect.offset.y),
415
+ radius: partOf(bound, 'radius', effect.radius),
416
+ spread: partOf(bound, 'spread', effect.spread ?? 0),
417
+ };
418
+ }
419
+ if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR')
420
+ return { type: effect.type, radius: partOf(bound, 'radius', effect.radius) };
421
+ return { type: effect.type };
422
+ }
423
+
424
+ /** What Figma reports for a layout grid: the fields its alignment takes, each by its variable when bound, and its colour. */
425
+ export function reportedGrid(grid: LayoutGrid): unknown {
426
+ const read = grid as unknown as Record<string, unknown>;
427
+ const out: Record<string, unknown> = { pattern: grid.pattern };
428
+ if (grid.pattern !== 'GRID') out.alignment = grid.alignment;
429
+ for (const field of GRID_FIELDS[grid.pattern === 'GRID' ? 'GRID' : grid.alignment] ?? []) {
430
+ out[field] = partOf(grid.boundVariables as Record<string, VariableAlias> | undefined, field, read[field] ?? 0);
431
+ }
432
+ out.color = grid.color ? rgbaOf(grid.color) : null;
433
+ return out;
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Apply: one token on one field of a layer no blueprint built (`PLAN_APPLY`), and the conventions' bindings. No
438
+ // component, no receipt, no stamp: the layer and the run are made up around the one call to `writeField`.
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * The field table's kind for a Figma layer: a slot is a `SLOT`, a text a `TEXT`, a line a `LINE`, every other frame
443
+ * kind a `FRAME`. An instance and a component definition read as frames, as the roster counts them: Figma gives them
444
+ * a frame's fields, and on an instance a write is an override. The table's `INSTANCE` is what a component file may
445
+ * say of one, and the format leaves overrides out.
446
+ */
447
+ export const applyKindOf = (node: SceneNode): Layer['kind'] =>
448
+ node.type === 'SLOT' || node.type === 'TEXT' || node.type === 'LINE' ? node.type : 'FRAME';
449
+
450
+ /** The layer `writeField` reads: the node, with the one field holding the token. */
451
+ export function applyLayer(node: SceneNode, field: string, path: string): Layer {
452
+ const token: Value = { kind: 'token', path };
453
+ // A field written as a list (paints, dashes) holds the token alone.
454
+ const list = FIELDS[field]?.value === 'paints' || FIELDS[field]?.value === 'dashes';
455
+ return { kind: applyKindOf(node), address: node.name, fields: { [field]: list ? [token] : token }, children: [] };
456
+ }
457
+
458
+ /**
459
+ * What `writeField` asks of a run, for one token: its variable, where the field binds it, and its value at the declared
460
+ * defaults. A composite (a gradient, a shadow, a grid) is a component file's to write, part by part: Apply's plan refuses it.
461
+ */
462
+ export function resolvedFor(variable: Variable | undefined, atDefaults?: { value: VariableValue | number[]; context: string }): Resolved {
463
+ const none = (what: string) => (): never => {
464
+ throw new Error(`Apply writes a token's variable or its value, not ${what}`);
465
+ };
466
+ const bound = (): Variable => variable ?? none('a token with no variable')();
467
+ return {
468
+ get page() {
469
+ return figma.currentPage;
470
+ },
471
+ float: bound,
472
+ color: bound,
473
+ textStyle: none('a text style'),
474
+ effectStyle: none('an effect style'),
475
+ component: none('a component'),
476
+ componentSource: none('a component'),
477
+ preferred: none('a component'),
478
+ mode: none('a mode'),
479
+ textStyles: () => [],
480
+ pending: [],
481
+ notes: [],
482
+ valueOf: () => atDefaults,
483
+ composite: () => undefined,
484
+ variable: bound,
485
+ // A text field is bound whole; Apply writes no text, so no line height is written as a value.
486
+ textValue: () => undefined,
487
+ stack: () => undefined,
488
+ shows: none('what a property shows'),
489
+ };
490
+ }
491
+
492
+ /** Writes one token on one field of a node, through `writeField`. */
493
+ export function writeToken(node: SceneNode, field: string, path: string, resolved: Resolved): Promise<FieldWrite> {
494
+ // `writeField` reads a run's name, its resolved names and its warnings; Apply says what it writes in its plan.
495
+ const run = { spec: { name: node.name }, resolved, warnings: [] as string[] } as unknown as Build;
496
+ return writeField(run, node, applyLayer(node, field, path), field);
497
+ }