@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,172 @@
1
+ /**
2
+ * Reads Figma back and reports where every token stands.
3
+ *
4
+ * ### Why it is asked for rather than sent
5
+ *
6
+ * The answer is only true for the instant it was taken. Figma can change under
7
+ * the plugin — a designer editing a variable in another panel raises no event
8
+ * this codebase watches — so the payload carries `checkedAt` and the pane offers
9
+ * to look again. A standing claim with no timestamp is the badge that was caught
10
+ * lying in smell #22, wearing different clothes.
11
+ *
12
+ * It also costs a full read of the file's variables, which is not worth paying
13
+ * on load for a pane the user may never open.
14
+ *
15
+ * ### Why it does not build its own comparison
16
+ *
17
+ * Everything here comes from `utils/correspondence.ts`, which is also what
18
+ * `VariableSyncEngine` plans from, over the same sum (`utils/sum.ts`) and the
19
+ * same account of where each collection stands (`utils/standing.ts`). Two
20
+ * implementations of "does Figma agree with the document" would eventually
21
+ * disagree, and a pane reporting drift beside a plan reporting none is precisely
22
+ * the defect this milestone exists to avoid. This handler only renders the answer.
23
+ */
24
+
25
+ import type { InspectionPayload, ModeReport, Response, TokenReport } from '../../shared/protocol.js';
26
+ import type { CorrespondenceState } from '../../shared/protocol.js';
27
+ import { getState } from '../runtimeState.js';
28
+ import { compareToken, describeState, variableNameOfSymbol, type TokenCorrespondence } from '../utils/correspondence.js';
29
+ import { indexFigmaCollections, type FigmaCollectionIndex } from '../utils/figmaCollectionIndex.js';
30
+ import { fromFigmaValue } from '../utils/figmaTokenAdapter.js';
31
+ import { flatMap } from '../utils/lists.js';
32
+ import { figmaCollectionNameFor } from '../utils/resolverModes.js';
33
+ import { standingOf } from '../utils/standing.js';
34
+ import { buildSum, type Sum } from '../utils/sum.js';
35
+
36
+ export async function INSPECT_CORRESPONDENCE(): Promise<void> {
37
+ const state = getState();
38
+ if (!state.resolver) {
39
+ postToUi({
40
+ type: 'ERROR',
41
+ payload: {
42
+ message: 'Load a resolver document before inspecting.',
43
+ code: 'NO_RESOLVER',
44
+ commandType: 'INSPECT_CORRESPONDENCE',
45
+ },
46
+ });
47
+ return;
48
+ }
49
+
50
+ const collectionName = figmaCollectionNameFor(state.resolver);
51
+ let sum: Sum;
52
+ try {
53
+ sum = buildSum(state.resolver, collectionName, state.conversionMap);
54
+ } catch (error) {
55
+ postToUi({
56
+ type: 'ERROR',
57
+ payload: {
58
+ message: error instanceof Error ? error.message : String(error),
59
+ code: 'INVALID_DOCUMENT',
60
+ commandType: 'INSPECT_CORRESPONDENCE',
61
+ },
62
+ });
63
+ return;
64
+ }
65
+
66
+ const figmaIndex = await indexFigmaCollections();
67
+ // A mode a collection does not have yet resolves to an id Figma holds nothing
68
+ // under, which reads back as "no value to compare" rather than as agreement.
69
+ const standing = standingOf(sum, figmaIndex);
70
+
71
+ const counts = emptyCounts();
72
+ const tokens: TokenReport[] = [];
73
+
74
+ for (const token of sum.tokens) {
75
+ const correspondence = compareToken({ token, sum, standing, figmaIndex });
76
+
77
+ const { state: tokenState, note } = describeState(correspondence);
78
+ counts[tokenState] += 1;
79
+ tokens.push(toReport(correspondence, tokenState, note, figmaIndex));
80
+ }
81
+
82
+ postToUi({
83
+ type: 'CORRESPONDENCE',
84
+ payload: {
85
+ checkedAt: new Date().toISOString(),
86
+ collectionName,
87
+ tokens,
88
+ counts,
89
+ } satisfies InspectionPayload,
90
+ });
91
+ }
92
+
93
+ function toReport(
94
+ correspondence: TokenCorrespondence,
95
+ state: CorrespondenceState,
96
+ note: string | undefined,
97
+ figmaIndex: FigmaCollectionIndex,
98
+ ): TokenReport {
99
+ const { routing } = correspondence;
100
+ const variableType = routing.kind === 'variable' ? routing.variableType : undefined;
101
+
102
+ // A mode is named with its collection, `theme / dark`, and the links of a
103
+ // chain are each in a collection of their own, so no two rows of a token
104
+ // share a name.
105
+ const modes: ModeReport[] = flatMap(correspondence.variables, (variable) =>
106
+ variable.modes.map((mode) => ({
107
+ mode: `${variable.collection} / ${mode.mode}`,
108
+ present: mode.present,
109
+ documentValue: render(mode.documentValue, variableType, figmaIndex),
110
+ figmaValue: render(mode.figmaValue, variableType, figmaIndex),
111
+ agrees: mode.agrees,
112
+ })),
113
+ );
114
+
115
+ return {
116
+ path: correspondence.path,
117
+ type: correspondence.type,
118
+ state,
119
+ ...(note ? { note } : {}),
120
+ ...(variableType ? { variableName: correspondence.variableName } : {}),
121
+ modes,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Renders one side for the screen.
127
+ *
128
+ * **Both sides go through the same function.** Showing the document's value as
129
+ * the token wrote it (`#0066CC`, `{colorSpace, components}`) against Figma's
130
+ * (`{r: 0, g: 0.4, b: 0.8, a: 1}`) would put two representations of one colour
131
+ * side by side and leave the reader to do the conversion — which is the work
132
+ * this pane exists to have already done.
133
+ *
134
+ * This is display only. `figmaValuesEqual` remains the sole judge of agreement:
135
+ * rounding here and comparing the strings would turn a display precision into a
136
+ * correctness rule, and Figma stores float32, so `0.4` reads back as
137
+ * `0.4000000059604645`.
138
+ */
139
+ function render(
140
+ value: VariableValue | null,
141
+ variableType: VariableResolvedDataType | undefined,
142
+ figmaIndex: FigmaCollectionIndex,
143
+ ): string | null {
144
+ if (value === null || variableType === undefined) return null;
145
+ // A document-side alias may point at a variable the next sync creates; its
146
+ // symbolic id carries the name, which reads better than the symbol.
147
+ const readable = fromFigmaValue(value, variableType, (id) => figmaIndex.variableById[id]?.name ?? variableNameOfSymbol(id));
148
+ return typeof readable === 'string' ? readable : String(readable);
149
+ }
150
+
151
+ /**
152
+ * Every state at zero.
153
+ *
154
+ * Seeded rather than accumulated so the UI can read `counts.drifted` without
155
+ * asking whether the key exists — an absent key and a zero mean the same thing
156
+ * here, and only one of them is safe to render.
157
+ */
158
+ function emptyCounts(): Record<CorrespondenceState, number> {
159
+ return {
160
+ current: 0,
161
+ drifted: 0,
162
+ outdated: 0,
163
+ absent: 0,
164
+ adopted: 0,
165
+ blocked: 0,
166
+ not_inspected: 0,
167
+ };
168
+ }
169
+
170
+ function postToUi(response: Response): void {
171
+ figma.ui.postMessage(response);
172
+ }
@@ -0,0 +1,86 @@
1
+ import type { Command, Response } from '../../shared/protocol.js';
2
+ import type { PropertyConvention } from '../../shared/conventions.js';
3
+ import { getState, setState } from '../runtimeState.js';
4
+
5
+ type LoadConventionsCommand = Extract<Command, { type: 'LOAD_CONVENTIONS' }>;
6
+
7
+ function postToUi(message: Response): void {
8
+ figma.ui.postMessage(message);
9
+ }
10
+
11
+ function isObject(value: unknown): value is Record<string, unknown> {
12
+ return !!value && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+
15
+ function parseConventions(json: string): PropertyConvention[] {
16
+ const parsed = JSON.parse(json) as unknown;
17
+ if (!Array.isArray(parsed)) {
18
+ throw new Error('Conventions file must be a JSON array.');
19
+ }
20
+
21
+ return parsed.map((entry, index) => {
22
+ if (!isObject(entry)) {
23
+ throw new Error(`Convention at index ${index} must be an object.`);
24
+ }
25
+
26
+ const id = entry.id;
27
+ const pattern = entry.pattern;
28
+ const property = entry.property;
29
+ const nodeTypes = entry.nodeTypes;
30
+ const variableType = entry.variableType;
31
+ const description = entry.description;
32
+
33
+ if (typeof id !== 'string' || !id.trim()) {
34
+ throw new Error(`Convention at index ${index} has invalid "id".`);
35
+ }
36
+ if (typeof pattern !== 'string' || !pattern.trim()) {
37
+ throw new Error(`Convention "${id}" has invalid "pattern".`);
38
+ }
39
+ if (typeof property !== 'string' || !property.trim()) {
40
+ throw new Error(`Convention "${id}" has invalid "property".`);
41
+ }
42
+ if (!Array.isArray(nodeTypes) || nodeTypes.some((nodeType) => typeof nodeType !== 'string')) {
43
+ throw new Error(`Convention "${id}" has invalid "nodeTypes".`);
44
+ }
45
+ if (typeof variableType !== 'string' || !variableType.trim()) {
46
+ throw new Error(`Convention "${id}" has invalid "variableType".`);
47
+ }
48
+
49
+ return {
50
+ id,
51
+ pattern,
52
+ property,
53
+ nodeTypes: nodeTypes as NodeType[],
54
+ variableType: variableType as VariableResolvedDataType,
55
+ ...(typeof description === 'string' ? { description } : {}),
56
+ };
57
+ });
58
+ }
59
+
60
+ export async function LOAD_CONVENTIONS(payload: LoadConventionsCommand['payload']): Promise<void> {
61
+ try {
62
+ const conventions = parseConventions(payload.json);
63
+ setState({ conventions });
64
+
65
+ postToUi({
66
+ type: 'CONVENTIONS_LOADED',
67
+ payload: { count: conventions.length },
68
+ });
69
+ } catch (error) {
70
+ postToUi({
71
+ type: 'ERROR',
72
+ payload: {
73
+ message: error instanceof Error ? error.message : String(error),
74
+ code: 'INVALID_CONVENTIONS',
75
+ commandType: 'LOAD_CONVENTIONS',
76
+ },
77
+ });
78
+ }
79
+ }
80
+
81
+ export async function GET_CONVENTIONS(): Promise<void> {
82
+ postToUi({
83
+ type: 'CONVENTIONS_LOADED',
84
+ payload: { count: getState().conventions.length },
85
+ });
86
+ }
@@ -0,0 +1,253 @@
1
+ import { load, Resolver, type Collision, type ResolverDocument } from '@toghen/lib';
2
+ import type { Operation } from '../../shared/operations.js';
3
+ import type { Command, Composition, PlanPayload, Response } from '../../shared/protocol.js';
4
+ import { getState, setState } from '../runtimeState.js';
5
+ import { buildDocumentStatePayload } from '../utils/buildPayloads.js';
6
+ import { migrateRootRecord } from '../utils/rootRecord.js';
7
+ import { resolveSumModes } from '../utils/resolverModes.js';
8
+ import { buildSyncStatePayload } from '../utils/syncState.js';
9
+ import { buildTokenGraph } from './tokenGraph.js';
10
+ import { forgetRecent, loadRecents, rememberRecent, summarize } from '../utils/recentStore.js';
11
+
12
+ type LoadDocumentCommand = Extract<Command, { type: 'LOAD_DOCUMENT' }>;
13
+ type LoadRecentCommand = Extract<Command, { type: 'LOAD_RECENT' }>;
14
+ type ForgetRecentCommand = Extract<Command, { type: 'FORGET_RECENT' }>;
15
+
16
+ /**
17
+ * Loads what the screen or the command line picked, through the core's `load`: a resolver document whose `$ref`s
18
+ * name picked files, one token file, or several in order, under an overrides map when one is given. The plugin
19
+ * reads no file itself: `read` is a lookup among the files that came with the command, and a file that is not
20
+ * among them is the core's error, naming the reference and the document that holds it.
21
+ */
22
+ export async function LOAD_DOCUMENT({ files, entry, name, overrides, dryRun }: LoadDocumentCommand['payload']): Promise<void> {
23
+ const texts = new Map(files.map((file) => [file.path, file.text]));
24
+ let loaded: Awaited<ReturnType<typeof load>>;
25
+ let form: Composition['form'];
26
+ try {
27
+ loaded = await load(entry, {
28
+ read: async (location) => {
29
+ const text = texts.get(location);
30
+ if (text === undefined) throw new Error('It is not among the files picked.');
31
+ return text;
32
+ },
33
+ ...(name !== undefined ? { name } : {}),
34
+ ...(overrides !== undefined ? { overrides } : {}),
35
+ });
36
+ // The core tells the forms apart by `resolutionOrder`; the composition says which it was (R9).
37
+ form =
38
+ typeof entry === 'string' && Array.isArray((JSON.parse(texts.get(entry)!) as { resolutionOrder?: unknown }).resolutionOrder)
39
+ ? 'resolver'
40
+ : 'tokens';
41
+ } catch (error) {
42
+ // The core's own words: they name the file, the reference, and what it is not (R13).
43
+ postError(error instanceof Error ? error.message : 'The document could not be loaded.', 'LOAD_DOCUMENT');
44
+ return;
45
+ }
46
+
47
+ const composition: Composition = {
48
+ form,
49
+ entry,
50
+ reads: loaded.reads,
51
+ ...(overrides !== undefined && loaded.overrides ? { overrides: { map: overrides, ...loaded.overrides } } : {}),
52
+ };
53
+ const report = loadReport(loaded.collisions, composition.overrides?.unused ?? []);
54
+ await open(loaded.document, composition, labelOf(entry), { remember: true, dryRun: dryRun === true, ...(report ? { report } : {}) });
55
+ }
56
+
57
+ /**
58
+ * Loads a document the user loaded before, from the bundle kept for them.
59
+ *
60
+ * The bundle is self-contained, so there is nothing to read: it goes to the same tail as a fresh load, deliberately,
61
+ * since one way in means one set of behaviours to keep correct. The composition is the one the stored load had, marked
62
+ * as coming from the bundle. Its collisions were reported when it was first loaded, and are not reported again.
63
+ */
64
+ export async function LOAD_RECENT(payload: LoadRecentCommand['payload']): Promise<void> {
65
+ const entry = (await loadRecents()).find((recent) => recent.key === payload.key);
66
+
67
+ if (entry === undefined) {
68
+ // The list the user clicked is no longer the list that exists — another
69
+ // window, or a write that shed entries to fit. Say so and re-post the
70
+ // truth rather than failing silently against a stale row.
71
+ postToUi({ type: 'RECENTS', payload: summarize(await loadRecents()) });
72
+ postError(`"${payload.key}" is no longer in your recents. Load the file again.`, 'LOAD_RECENT');
73
+ return;
74
+ }
75
+
76
+ await open(entry.document, { ...entry.composition, fromBundle: true }, entry.fileName, { remember: true });
77
+ }
78
+
79
+ export async function GET_RECENTS(): Promise<void> {
80
+ postToUi({ type: 'RECENTS', payload: summarize(await loadRecents()) });
81
+ }
82
+
83
+ export async function FORGET_RECENT(payload: ForgetRecentCommand['payload']): Promise<void> {
84
+ postToUi({ type: 'RECENTS', payload: summarize(await forgetRecent(payload.key)) });
85
+ }
86
+
87
+ /** What the Source card and the recents list name a load by: the entry's file name, or its files' names in order. */
88
+ function labelOf(entry: string | string[]): string {
89
+ return (typeof entry === 'string' ? [entry] : entry).map((path) => path.slice(path.lastIndexOf('/') + 1)).join(', ');
90
+ }
91
+
92
+ /**
93
+ * The rows a load reports, in a plan's shape so the result surface draws them (R2): one per collision, and one per
94
+ * key of the overrides map that matched no reference (R14). A collision whose values differ is a warning, since the
95
+ * earlier value is lost; one whose values are equal as written is a note, quiet (R4). Undefined when there is nothing to say.
96
+ */
97
+ export function loadReport(collisions: Collision[], unused: string[]): PlanPayload | undefined {
98
+ const operations: Operation[] = [
99
+ ...collisions.map((collision): Operation => {
100
+ const scope =
101
+ collision.set !== undefined ? `set "${collision.set}"` : `context "${collision.context}" of modifier "${collision.modifier}"`;
102
+ const files = collision.files.map((file) => `"${file}"`).join(', ');
103
+ return collision.redundant
104
+ ? { kind: 'NOTE', message: `${collision.path} is given the same value by ${files} in ${scope}.` }
105
+ : { kind: 'WARNING', path: collision.path, message: `Given a value by ${files} in ${scope}; the last wins.` };
106
+ }),
107
+ ...unused.map(
108
+ (key): Operation => ({
109
+ kind: 'WARNING',
110
+ message: `The overrides map's key "${key}" matched no reference, so nothing was read in its place.`,
111
+ }),
112
+ ),
113
+ ];
114
+ return operations.length > 0 ? { planType: 'load', operations } : undefined;
115
+ }
116
+
117
+ /**
118
+ * Resolves and announces a loaded document — however it arrived.
119
+ *
120
+ * @param remember - whether this load joins the recents list. Only a load that
121
+ * succeeded is remembered: an entry that cannot be loaded is worse than no
122
+ * entry, because it looks like one that can.
123
+ */
124
+ async function open(
125
+ doc: ResolverDocument,
126
+ composition: Composition,
127
+ fileName: string | undefined,
128
+ { remember, dryRun = false, report }: { remember: boolean; dryRun?: boolean; report?: PlanPayload },
129
+ ): Promise<void> {
130
+ try {
131
+ const resolver = new Resolver(doc);
132
+ // The sum: the defaults and each context on its own, never the product of
133
+ // every combination, which a large document cannot finish. A modifier with
134
+ // no `default` is a legal document that has no sum; this refuses it, naming
135
+ // each such modifier, rather than the generic "Invalid document".
136
+ const designSystem = resolveSumModes(resolver).ds;
137
+
138
+ // What a sync wrote is read from the stamps on the file's own variables and styles, never from the document:
139
+ // a target's ids belong to one file, and one document feeds several. A file an older build synced holds a root
140
+ // record instead, turned into stamps here, once: the document says which fields Figma agrees on. A dry run's load
141
+ // keeps them in memory and writes nothing, so the record waits for the first real load.
142
+ await migrateRootRecord(resolver, getState().conversionMap, { dryRun });
143
+
144
+ setState({
145
+ resolver,
146
+ resolverDoc: doc,
147
+ designSystem,
148
+ fileName: fileName ?? null,
149
+ composition,
150
+ lastPlan: null,
151
+ lastPlanType: null,
152
+ });
153
+
154
+ // The load's rows ride on its own answer rather than on a `PLAN_READY` of their own: the command line takes the
155
+ // first `PLAN_READY` after its next command as that command's plan, and a second message could arrive first.
156
+ postToUi({
157
+ type: 'DOCUMENT_LOADED',
158
+ payload: {
159
+ ...buildDocumentStatePayload(resolver, getState().conversionMap, getState().fileName),
160
+ composition,
161
+ ...(report ? { report } : {}),
162
+ },
163
+ });
164
+
165
+ // The Tokens pane browses the document's own shape, which needs no look at Figma.
166
+ postToUi({ type: 'TOKEN_GRAPH', payload: buildTokenGraph(resolver) });
167
+
168
+ // Coverage only becomes computable now: without a document there is nothing
169
+ // to measure the file's stamps against.
170
+ postToUi({
171
+ type: 'SYNC_STATE',
172
+ payload: await buildSyncStatePayload(resolver),
173
+ });
174
+
175
+ if (remember) {
176
+ const state = buildDocumentStatePayload(resolver, getState().conversionMap);
177
+ const { fromBundle: _fromBundle, ...stored } = composition;
178
+ const recents = await rememberRecent({
179
+ // The name identifies the system; the filename is a fallback, because
180
+ // every one of these files is called `*.resolver.json`.
181
+ key: resolver.name?.trim() || fileName || state.name,
182
+ name: resolver.name?.trim() || fileName || state.name,
183
+ version: resolver.getDeclaredVersion(),
184
+ description: resolver.description,
185
+ tokenCount: state.tokenCount,
186
+ modes: state.modes,
187
+ modifiers: Object.keys(state.modifiers),
188
+ fileName,
189
+ loadedAt: new Date().toISOString(),
190
+ // The bundle and how it was made, never the files: it loads again with none.
191
+ document: doc,
192
+ composition: stored,
193
+ });
194
+ postToUi({ type: 'RECENTS', payload: summarize(recents) });
195
+ }
196
+ } catch (error) {
197
+ postError(error instanceof Error ? error.message : 'Invalid document', 'LOAD_DOCUMENT');
198
+ }
199
+ }
200
+
201
+ function postError(message: string, commandType: string): void {
202
+ postToUi({ type: 'ERROR', payload: { message, code: 'INVALID_DOCUMENT', commandType } });
203
+ }
204
+
205
+ export async function GET_DOCUMENT_STATE(): Promise<void> {
206
+ const state = getState();
207
+ if (!state.resolver) {
208
+ postToUi({
209
+ type: 'DOCUMENT_STATE',
210
+ payload: {
211
+ loaded: false,
212
+ name: 'Untitled',
213
+ tokenCount: 0,
214
+ modifiers: {},
215
+ modes: [],
216
+ modifierDefaults: {},
217
+ defaultMode: '',
218
+ styleCount: 0,
219
+ },
220
+ });
221
+ return;
222
+ }
223
+
224
+ postToUi({
225
+ type: 'DOCUMENT_STATE',
226
+ payload: {
227
+ ...buildDocumentStatePayload(state.resolver, state.conversionMap, state.fileName),
228
+ ...(state.composition ? { composition: state.composition } : {}),
229
+ },
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Answers what this Figma file records about having been synced.
235
+ *
236
+ * Answerable with no document loaded, which is the point: the plugin opens into
237
+ * a file, not into a document, and "you synced this file before" is the first
238
+ * useful thing it can say.
239
+ */
240
+ export async function GET_SYNC_STATE(): Promise<void> {
241
+ // A file an older build synced is turned into stamps with a document in hand, which says what Figma agrees on;
242
+ // asked before one is loaded, its root record waits for the first load. A dry run's load is not one.
243
+ const { resolver, conversionMap, unwrittenStamps } = getState();
244
+ if (resolver && !unwrittenStamps) await migrateRootRecord(resolver, conversionMap);
245
+ postToUi({
246
+ type: 'SYNC_STATE',
247
+ payload: await buildSyncStatePayload(resolver, conversionMap),
248
+ });
249
+ }
250
+
251
+ function postToUi(message: Response): void {
252
+ figma.ui.postMessage(message);
253
+ }