@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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design system, as a component file asks about it (`shared/components/axes.ts`).
|
|
3
|
+
*
|
|
4
|
+
* The reader holds no `@toghen/lib`, so what a file may ask (the contexts of a modifier,
|
|
5
|
+
* the part axes a token group has) is answered here, by lib: in the
|
|
6
|
+
* repository for the command line, and on the main thread for a template kept in a file. The answers are asked for lazily: a file that takes nothing from the tokens
|
|
7
|
+
* never builds the sum, so a document the sum cannot be built from (a modifier with no
|
|
8
|
+
* default) only matters to a file that needs it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { referencePath, type Collection, type Resolver } from '@toghen/lib';
|
|
12
|
+
import type { DesignSource } from '../../shared/components/axes.js';
|
|
13
|
+
import type { GroupInfo } from '../../shared/components/model.js';
|
|
14
|
+
import { foldsOf, unfoldedFor, type Folds } from './folds.js';
|
|
15
|
+
import { figmaContexts, pathsInAnyMode, resolveSumModes, snapshotModes } from './resolverModes.js';
|
|
16
|
+
import type { ModeSnapshot } from './resolverModes.js';
|
|
17
|
+
|
|
18
|
+
interface Modifier {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Every context, the default first: the default member of an axis, as the first mode of a Figma collection. */
|
|
21
|
+
contexts: string[];
|
|
22
|
+
/** The contexts Figma gets, in that order (`figmaContexts`). Read when asked: a modifier's bad `figma.include` is only the problem of a file that uses it. */
|
|
23
|
+
figma(): string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function designSourceOf(resolver: Resolver): DesignSource {
|
|
27
|
+
let built:
|
|
28
|
+
| { modifiers: Modifier[]; paths: string[]; collection: string; tokens: Collection; resolution: ReturnType<typeof resolveSumModes> }
|
|
29
|
+
| undefined;
|
|
30
|
+
const foldsCache = new Map<string, Folds>();
|
|
31
|
+
// The values of every token in every context, read only for a group with a composite `border` in it.
|
|
32
|
+
let snapshots: ModeSnapshot[] | undefined;
|
|
33
|
+
const design = () => {
|
|
34
|
+
if (built) return built;
|
|
35
|
+
// The sum, as variable sync builds it; the modifiers in the document's declared order, with their spelling.
|
|
36
|
+
const resolution = resolveSumModes(resolver);
|
|
37
|
+
const { ds, dsCollectionName } = resolution;
|
|
38
|
+
const collection = ds.getCollection(dsCollectionName);
|
|
39
|
+
// `resolveSumModes` has refused a modifier with no default.
|
|
40
|
+
const modifiers = resolver.getModifiers().map(
|
|
41
|
+
(modifier): Modifier => ({
|
|
42
|
+
name: modifier.name,
|
|
43
|
+
contexts: [modifier.default!, ...modifier.contexts.filter((context) => context !== modifier.default)],
|
|
44
|
+
figma: () => figmaContexts(modifier),
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
built = {
|
|
48
|
+
modifiers,
|
|
49
|
+
paths: [...pathsInAnyMode(resolution).keys()],
|
|
50
|
+
collection: dsCollectionName,
|
|
51
|
+
tokens: collection as Collection,
|
|
52
|
+
resolution,
|
|
53
|
+
};
|
|
54
|
+
return built;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
// A component file takes the contexts Figma gets: the others have no mode to pin.
|
|
59
|
+
contexts: (modifier) =>
|
|
60
|
+
design()
|
|
61
|
+
.modifiers.find((entry) => entry.name === modifier)
|
|
62
|
+
?.figma(),
|
|
63
|
+
leftOut: (modifier) => {
|
|
64
|
+
const found = design().modifiers.find((entry) => entry.name === modifier);
|
|
65
|
+
const gets = found?.figma();
|
|
66
|
+
return found && gets ? found.contexts.filter((context) => !gets.includes(context)) : undefined;
|
|
67
|
+
},
|
|
68
|
+
partsOf: (group, layoutParts) => {
|
|
69
|
+
const { paths, tokens } = design();
|
|
70
|
+
if (tokens.getNodeKind(group) === 'missing') return undefined;
|
|
71
|
+
// This file's own layout decides its component; the others keep what the files checked before it said.
|
|
72
|
+
const stored = unfoldedFor(resolver).filter((name) => name !== group);
|
|
73
|
+
const unfold = layoutParts ? [...stored, group] : stored;
|
|
74
|
+
const key = unfold.join('\n');
|
|
75
|
+
let folds = foldsCache.get(key);
|
|
76
|
+
if (!folds) foldsCache.set(key, (folds = foldsOf(tokens, paths, unfold)));
|
|
77
|
+
return folds.components.find((component) => component.group === group)?.axes ?? [];
|
|
78
|
+
},
|
|
79
|
+
children: (path) => {
|
|
80
|
+
const { tokens } = design();
|
|
81
|
+
if (tokens.getNodeKind(path) === 'token') return 'token';
|
|
82
|
+
// One name for each child, tokens and groups alike, in document order (lib's `childrenOf`).
|
|
83
|
+
const names = tokens.childrenOf(path).map((child) => child.key);
|
|
84
|
+
return names.length > 0 ? names : undefined;
|
|
85
|
+
},
|
|
86
|
+
group: (path, selection = {}) => {
|
|
87
|
+
const { tokens, collection, resolution } = design();
|
|
88
|
+
// The token each key is: in the group, or in a part group the member selects (`button.primary.backgroundColor`),
|
|
89
|
+
// as lib's `applies` rules: more parts wins, then the later axis; the document's key order never matters.
|
|
90
|
+
const at = tokens.applies(path, selection);
|
|
91
|
+
const keys = Object.keys(at);
|
|
92
|
+
const kind = tokens.getNodeKind(path);
|
|
93
|
+
if (keys.length === 0) return kind === 'group' ? { keys } : kind === 'token' ? 'token' : undefined;
|
|
94
|
+
|
|
95
|
+
const info: GroupInfo = { keys };
|
|
96
|
+
const moved = keys.filter((key) => at[key] !== `${path}.${key}`);
|
|
97
|
+
if (moved.length > 0) {
|
|
98
|
+
info.paths = {};
|
|
99
|
+
for (const key of moved) info.paths[key] = at[key];
|
|
100
|
+
}
|
|
101
|
+
snapshots ??= snapshotModes(resolution);
|
|
102
|
+
const prefix = `${collection}.`;
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
// `background` binds the fill only when its token is a colour in every context.
|
|
105
|
+
if (key === 'background' && snapshots.every((snapshot) => snapshot.pathToToken.get(`${prefix}${at[key]}`)?.type === 'color'))
|
|
106
|
+
info.colors = [key];
|
|
107
|
+
// A composite `border`: its members are what binds. A member that is one whole reference binds the
|
|
108
|
+
// token it refers to (in the default context); a style that is not solid, in any context, is not bindable.
|
|
109
|
+
const token = (snapshot: ModeSnapshot) => snapshot.pathToToken.get(`${prefix}${at[key]}`);
|
|
110
|
+
if (snapshots.every((snapshot) => token(snapshot)?.type !== 'border')) continue;
|
|
111
|
+
const border: NonNullable<GroupInfo['borders']>[string] = {};
|
|
112
|
+
const members = token(snapshots[0])?.memberOf ?? {};
|
|
113
|
+
if (members.color) border.color = referencePath(members.color);
|
|
114
|
+
if (members.width) border.width = referencePath(members.width);
|
|
115
|
+
if (snapshots.some((snapshot) => token(snapshot) && (token(snapshot)?.value as { style?: unknown } | undefined)?.style !== 'solid'))
|
|
116
|
+
border.unsolid = true;
|
|
117
|
+
(info.borders ??= {})[key] = border;
|
|
118
|
+
}
|
|
119
|
+
return info;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getStamp, sortOut, type Stamp } from '../components/stamp.js';
|
|
2
|
+
|
|
3
|
+
export interface IndexedMode {
|
|
4
|
+
modeId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface IndexedCollection {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
modes: IndexedMode[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IndexedVariable {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
variableCollectionId: string;
|
|
18
|
+
resolvedType: VariableResolvedDataType;
|
|
19
|
+
/**
|
|
20
|
+
* What Figma currently holds, per mode id.
|
|
21
|
+
*
|
|
22
|
+
* The plugin used to index a variable's identity and never its contents, so
|
|
23
|
+
* it could not see a value a designer had changed by hand: every difference
|
|
24
|
+
* it detected was a difference in the *document*. This is the other half of
|
|
25
|
+
* the comparison.
|
|
26
|
+
*/
|
|
27
|
+
valuesByMode: Record<string, VariableValue>;
|
|
28
|
+
/** The code syntax Figma holds for the web, when it holds one. */
|
|
29
|
+
codeSyntaxWeb?: string;
|
|
30
|
+
/** What the variable may be bound to, in Figma's own order. */
|
|
31
|
+
scopes: VariableScope[];
|
|
32
|
+
/** What Toghen keeps on the variable: the token it holds and a receipt of what it wrote. Absent on one Toghen did not write. */
|
|
33
|
+
stamp?: Stamp;
|
|
34
|
+
/** Toghen's variable, moved: its stamp names another id, which the next write replaces with its own (`sortOut`). */
|
|
35
|
+
moved?: true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FigmaCollectionIndex {
|
|
39
|
+
byName: Record<string, IndexedCollection>;
|
|
40
|
+
byId: Record<string, IndexedCollection>;
|
|
41
|
+
variableById: Record<string, IndexedVariable>;
|
|
42
|
+
variableByCollectionAndName: Record<string, IndexedVariable>;
|
|
43
|
+
/** The variables stamped for each token, by the key the sync gives it: one, or more where a token moved collection and the old one is still there. A copy is not among them. */
|
|
44
|
+
variablesByToken: Record<string, IndexedVariable[]>;
|
|
45
|
+
/** The copies a person made of a variable Toghen wrote, each with the original: never written, never removed, never counted as Toghen's. */
|
|
46
|
+
copies: Array<{ copy: IndexedVariable; of: IndexedVariable }>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pairKey(collectionId: string, variableName: string): string {
|
|
50
|
+
return `${collectionId}::${variableName}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function indexFigmaCollections(): Promise<FigmaCollectionIndex> {
|
|
54
|
+
const byName: Record<string, IndexedCollection> = {};
|
|
55
|
+
const byId: Record<string, IndexedCollection> = {};
|
|
56
|
+
const variableById: Record<string, IndexedVariable> = {};
|
|
57
|
+
const variableByCollectionAndName: Record<string, IndexedVariable> = {};
|
|
58
|
+
const variablesByToken: Record<string, IndexedVariable[]> = {};
|
|
59
|
+
|
|
60
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
61
|
+
for (const collection of collections) {
|
|
62
|
+
const indexedCollection: IndexedCollection = {
|
|
63
|
+
id: collection.id,
|
|
64
|
+
name: collection.name,
|
|
65
|
+
modes: collection.modes.map((mode) => ({ modeId: mode.modeId, name: mode.name })),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
byName[indexedCollection.name] = indexedCollection;
|
|
69
|
+
byId[indexedCollection.id] = indexedCollection;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const variables = await figma.variables.getLocalVariablesAsync();
|
|
73
|
+
const indexed: IndexedVariable[] = [];
|
|
74
|
+
for (const variable of variables) {
|
|
75
|
+
const stamp = getStamp(variable);
|
|
76
|
+
const indexedVariable: IndexedVariable = {
|
|
77
|
+
id: variable.id,
|
|
78
|
+
name: variable.name,
|
|
79
|
+
variableCollectionId: variable.variableCollectionId,
|
|
80
|
+
resolvedType: variable.resolvedType,
|
|
81
|
+
valuesByMode: { ...variable.valuesByMode },
|
|
82
|
+
scopes: [...(variable.scopes ?? ['ALL_SCOPES'])],
|
|
83
|
+
// measured 2026-10-08 (measured behaviour §10): `codeSyntax` reads back what `setVariableCodeSyntax` wrote, under the key 'WEB'
|
|
84
|
+
...(variable.codeSyntax?.WEB !== undefined ? { codeSyntaxWeb: variable.codeSyntax.WEB } : {}),
|
|
85
|
+
...(stamp ? { stamp } : {}),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
variableById[indexedVariable.id] = indexedVariable;
|
|
89
|
+
variableByCollectionAndName[pairKey(indexedVariable.variableCollectionId, indexedVariable.name)] = indexedVariable;
|
|
90
|
+
indexed.push(indexedVariable);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sorted = await sortOut(
|
|
94
|
+
indexed,
|
|
95
|
+
(variable) => variable.stamp,
|
|
96
|
+
(id) => variableById[id],
|
|
97
|
+
);
|
|
98
|
+
for (const variable of sorted.ours) {
|
|
99
|
+
if (sorted.moved.has(variable)) variable.moved = true;
|
|
100
|
+
const token = variable.stamp?.is.token;
|
|
101
|
+
if (token !== undefined) (variablesByToken[token] ??= []).push(variable);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
byName,
|
|
106
|
+
byId,
|
|
107
|
+
variableById,
|
|
108
|
+
variableByCollectionAndName,
|
|
109
|
+
variablesByToken,
|
|
110
|
+
copies: sorted.copies,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getIndexedVariableByCollectionAndName(
|
|
115
|
+
index: FigmaCollectionIndex,
|
|
116
|
+
collectionId: string,
|
|
117
|
+
variableName: string,
|
|
118
|
+
): IndexedVariable | undefined {
|
|
119
|
+
return index.variableByCollectionAndName[pairKey(collectionId, variableName)];
|
|
120
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeColorToSRGB01,
|
|
3
|
+
normalizeDimensionToPx,
|
|
4
|
+
normalizeGradient,
|
|
5
|
+
normalizeLineHeight,
|
|
6
|
+
type DimensionValue,
|
|
7
|
+
type GradientKind,
|
|
8
|
+
type GradientValue,
|
|
9
|
+
type ShadowValue,
|
|
10
|
+
type TypographyValue,
|
|
11
|
+
} from '@toghen/lib';
|
|
12
|
+
import { toFigmaFontFamily } from './figmaTokenAdapter.js';
|
|
13
|
+
|
|
14
|
+
export interface TextStyleProps {
|
|
15
|
+
fontFamily: string;
|
|
16
|
+
fontStyle: string;
|
|
17
|
+
fontSize: number;
|
|
18
|
+
lineHeight: LineHeight;
|
|
19
|
+
letterSpacing: LetterSpacing;
|
|
20
|
+
textCase: TextCase;
|
|
21
|
+
textDecoration: TextDecoration;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EffectStyleProps {
|
|
25
|
+
effects: Effect[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PaintStyleProps {
|
|
29
|
+
paints: Paint[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A length in pixels. The unit arithmetic is the library's, and so is the
|
|
38
|
+
* check: `normalizeDimensionToPx()` throws on anything that is not a dimension,
|
|
39
|
+
* which is why the cast below claims nothing it cannot back.
|
|
40
|
+
*/
|
|
41
|
+
function toPx(value: unknown): number {
|
|
42
|
+
return normalizeDimensionToPx(value as DimensionValue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Converts a W3C `lineHeight` to Figma's `LineHeight`.
|
|
47
|
+
*
|
|
48
|
+
* The library reads the value; this only reshapes it. The case that matters is
|
|
49
|
+
* the **unitless multiplier** — `1.5`, the W3C form and the one the demo uses.
|
|
50
|
+
* It is a ratio of the font size, which Figma expresses as PERCENT. Sending it
|
|
51
|
+
* down the PIXELS branch, as this once did, sets a 1.5 *pixel* line height and
|
|
52
|
+
* collapses the text. A length, `'24px'` or `{value, unit}`, stays in PIXELS.
|
|
53
|
+
*
|
|
54
|
+
* `'normal'` is not a W3C value and the library rejects it, but it has an exact
|
|
55
|
+
* Figma counterpart — AUTO, the font's own leading — so it is mapped here.
|
|
56
|
+
*/
|
|
57
|
+
export function toFigmaLineHeight(value: unknown): LineHeight {
|
|
58
|
+
if (typeof value === 'string' && value.trim().toLowerCase() === 'normal') {
|
|
59
|
+
return { unit: 'AUTO' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lineHeight = normalizeLineHeight(value as TypographyValue['lineHeight']);
|
|
63
|
+
switch (lineHeight.unit) {
|
|
64
|
+
case 'ratio':
|
|
65
|
+
return { unit: 'PERCENT', value: lineHeight.value * 100 };
|
|
66
|
+
case 'percent':
|
|
67
|
+
return { unit: 'PERCENT', value: lineHeight.value };
|
|
68
|
+
case 'px':
|
|
69
|
+
return { unit: 'PIXELS', value: lineHeight.value };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Converts a W3C `letterSpacing` to Figma's `LetterSpacing`.
|
|
75
|
+
*
|
|
76
|
+
* A W3C letterSpacing is a dimension, and goes to PIXELS through the library.
|
|
77
|
+
* `em` and `%` are not W3C units, but they are how letter spacing is usually
|
|
78
|
+
* written, and both are proportional to the font size — which Figma expresses
|
|
79
|
+
* as PERCENT, `1em` being 100%. This once hardcoded PIXELS, so `'0.05em'`
|
|
80
|
+
* became 0.05px (imperceptible, when 0.8px was meant) and `'5%'` became 5px.
|
|
81
|
+
*
|
|
82
|
+
* An absent letterSpacing is 0, as the library's `getLetterSpacingPx()` has it.
|
|
83
|
+
*/
|
|
84
|
+
export function toFigmaLetterSpacing(value: unknown): LetterSpacing {
|
|
85
|
+
if (value === undefined) {
|
|
86
|
+
return { unit: 'PIXELS', value: 0 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `{ value, unit: 'em' }`, the structured form of the string below.
|
|
90
|
+
if (isObject(value) && value.unit === 'em' && typeof value.value === 'number' && Number.isFinite(value.value)) {
|
|
91
|
+
return { unit: 'PERCENT', value: value.value * 100 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof value === 'string') {
|
|
95
|
+
const text = value.trim().toLowerCase();
|
|
96
|
+
const suffix = text.endsWith('%') ? '%' : text.endsWith('em') && !text.endsWith('rem') ? 'em' : '';
|
|
97
|
+
if (suffix) {
|
|
98
|
+
const amount = Number(text.slice(0, -suffix.length) || NaN);
|
|
99
|
+
if (!Number.isFinite(amount)) {
|
|
100
|
+
throw new Error(`Invalid letterSpacing '${value}'.`);
|
|
101
|
+
}
|
|
102
|
+
return { unit: 'PERCENT', value: suffix === 'em' ? amount * 100 : amount };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { unit: 'PIXELS', value: toPx(value) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeTextCase(value: unknown): TextCase {
|
|
110
|
+
if (typeof value !== 'string') return 'ORIGINAL';
|
|
111
|
+
switch (value.trim().toLowerCase()) {
|
|
112
|
+
case 'uppercase':
|
|
113
|
+
return 'UPPER';
|
|
114
|
+
case 'lowercase':
|
|
115
|
+
return 'LOWER';
|
|
116
|
+
case 'capitalize':
|
|
117
|
+
return 'TITLE';
|
|
118
|
+
default:
|
|
119
|
+
return 'ORIGINAL';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeTextDecoration(value: unknown): TextDecoration {
|
|
124
|
+
if (typeof value !== 'string') return 'NONE';
|
|
125
|
+
switch (value.trim().toLowerCase()) {
|
|
126
|
+
case 'underline':
|
|
127
|
+
return 'UNDERLINE';
|
|
128
|
+
case 'line-through':
|
|
129
|
+
case 'strikethrough':
|
|
130
|
+
return 'STRIKETHROUGH';
|
|
131
|
+
default:
|
|
132
|
+
return 'NONE';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Positions a linear gradient within a layer, rotated by `angleDeg`.
|
|
138
|
+
*
|
|
139
|
+
* A rotation about the layer's centre — `translate(½,½) ∘ rotate(θ) ∘
|
|
140
|
+
* translate(−½,−½)` — and nothing else:
|
|
141
|
+
*
|
|
142
|
+
* ```
|
|
143
|
+
* ⎡ cosθ −sinθ ½ − ½cosθ + ½sinθ ⎤
|
|
144
|
+
* ⎣ sinθ cosθ ½ − ½sinθ − ½cosθ ⎦
|
|
145
|
+
* ```
|
|
146
|
+
*
|
|
147
|
+
* At 0° that is exactly `[[1,0,0],[0,1,0]]`, the transform measured against a
|
|
148
|
+
* real file for a full-width left-to-right gradient — see
|
|
149
|
+
* `.reference/figma/measured_behaviour.md` §6. That identity is the anchor, and
|
|
150
|
+
* the test asserts it.
|
|
151
|
+
*
|
|
152
|
+
* This used to scale by ½ as well, so the layer sampled only the ramp's second
|
|
153
|
+
* half: a `#0066cc → #004d99` gradient rendered entirely between `#0059b2` and
|
|
154
|
+
* `#004d99`, with its first stop appearing nowhere on the shape. It went
|
|
155
|
+
* unnoticed because nothing tested the function and the probe only ever
|
|
156
|
+
* round-tripped a hand-written transform.
|
|
157
|
+
*
|
|
158
|
+
* ### Unmeasured
|
|
159
|
+
*
|
|
160
|
+
* At angles that are not multiples of 90° the gradient's extent along the
|
|
161
|
+
* diagonal exceeds the unit square, so a 45° gradient does not span corner to
|
|
162
|
+
* corner the way a CSS `linear-gradient` does. Correcting that needs a
|
|
163
|
+
* measurement of Figma's convention rather than more arithmetic. The W3C array
|
|
164
|
+
* form is always 0°, so no spec-conformant gradient is affected today.
|
|
165
|
+
*/
|
|
166
|
+
function angleToLinearTransform(angleDeg: number): Transform {
|
|
167
|
+
const radians = (angleDeg * Math.PI) / 180;
|
|
168
|
+
const cos = Math.cos(radians);
|
|
169
|
+
const sin = Math.sin(radians);
|
|
170
|
+
|
|
171
|
+
return [
|
|
172
|
+
[cos, -sin, 0.5 - 0.5 * cos + 0.5 * sin],
|
|
173
|
+
[sin, cos, 0.5 - 0.5 * sin - 0.5 * cos],
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function toTextStyleProps(value: unknown): TextStyleProps {
|
|
178
|
+
if (!isObject(value)) {
|
|
179
|
+
throw new Error('Typography token value must be an object.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No cast: every reader below accepts `unknown` and narrows for itself — the
|
|
183
|
+
// dimensions through the library, which throws on a value it cannot read — so
|
|
184
|
+
// asserting the shape here would only be a claim we cannot back.
|
|
185
|
+
return {
|
|
186
|
+
fontFamily: toFigmaFontFamily(value.fontFamily),
|
|
187
|
+
fontStyle: String(value.fontStyle ?? 'Regular'),
|
|
188
|
+
fontSize: toPx(value.fontSize),
|
|
189
|
+
lineHeight: toFigmaLineHeight(value.lineHeight),
|
|
190
|
+
letterSpacing: toFigmaLetterSpacing(value.letterSpacing),
|
|
191
|
+
textCase: normalizeTextCase(value.textTransform),
|
|
192
|
+
textDecoration: normalizeTextDecoration(value.textDecoration),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `currentColor` has no value until an element uses it, and Figma has no equivalent, so a style holding it
|
|
198
|
+
* (a shadow's colour, a gradient stop) cannot be built. Said in words rather than as the colour
|
|
199
|
+
* parser's complaint about a string that is not hex.
|
|
200
|
+
*/
|
|
201
|
+
function refuseCurrentColor(value: unknown): void {
|
|
202
|
+
if (typeof value === 'string' && value.trim().toLowerCase() === 'currentcolor') {
|
|
203
|
+
throw new Error('it holds currentColor, which Figma cannot hold.');
|
|
204
|
+
}
|
|
205
|
+
if (isObject(value) || Array.isArray(value)) {
|
|
206
|
+
Object.values(value).forEach(refuseCurrentColor);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function toEffectStyleProps(value: unknown): EffectStyleProps {
|
|
211
|
+
refuseCurrentColor(value);
|
|
212
|
+
const list = Array.isArray(value) ? value : [value];
|
|
213
|
+
|
|
214
|
+
const effects: Effect[] = list.map((raw) => {
|
|
215
|
+
if (!isObject(raw)) {
|
|
216
|
+
throw new Error('Shadow token value must be an object or array of objects.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const color = normalizeColorToSRGB01(raw.color as ShadowValue['color']);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
type: raw.inset ? 'INNER_SHADOW' : 'DROP_SHADOW',
|
|
223
|
+
color,
|
|
224
|
+
offset: {
|
|
225
|
+
x: toPx(raw.offsetX),
|
|
226
|
+
y: toPx(raw.offsetY),
|
|
227
|
+
},
|
|
228
|
+
radius: toPx(raw.blur),
|
|
229
|
+
spread: raw.spread === undefined ? 0 : toPx(raw.spread),
|
|
230
|
+
visible: true,
|
|
231
|
+
blendMode: 'NORMAL',
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return { effects };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const GRADIENT_PAINT_TYPE: Record<GradientKind, GradientPaint['type']> = {
|
|
239
|
+
linear: 'GRADIENT_LINEAR',
|
|
240
|
+
radial: 'GRADIENT_RADIAL',
|
|
241
|
+
angular: 'GRADIENT_ANGULAR',
|
|
242
|
+
conic: 'GRADIENT_ANGULAR',
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export function toPaintStyleProps(value: unknown): PaintStyleProps {
|
|
246
|
+
refuseCurrentColor(value);
|
|
247
|
+
// `normalizeGradient` accepts both gradient forms — the W3C array of stops
|
|
248
|
+
// (§9.7) and Toghen's object form. This used to require the object form and
|
|
249
|
+
// reject arrays outright, which made every spec-conformant gradient token
|
|
250
|
+
// throw before a single operation was planned.
|
|
251
|
+
const gradient = normalizeGradient(value as GradientValue);
|
|
252
|
+
|
|
253
|
+
const paint: GradientPaint = {
|
|
254
|
+
type: GRADIENT_PAINT_TYPE[gradient.type],
|
|
255
|
+
gradientStops: gradient.stops.map((stop) => ({
|
|
256
|
+
position: stop.position,
|
|
257
|
+
color: stop.color,
|
|
258
|
+
})),
|
|
259
|
+
gradientTransform: angleToLinearTransform(gradient.angleDeg),
|
|
260
|
+
visible: true,
|
|
261
|
+
opacity: 1,
|
|
262
|
+
blendMode: 'NORMAL',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
paints: [paint],
|
|
267
|
+
};
|
|
268
|
+
}
|