@apollion-dsi/tokens 7.9.0 → 7.10.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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Tokens Studio renderer (APOLLION-FIGMA-EXPORT) — project the Resolver slices
3
+ * to a Tokens Studio for Figma multi-file sync tree.
4
+ *
5
+ * **Shape** (`dist/tokens-studio/`, point Tokens Studio's folder sync at it):
6
+ * - one set file per Resolver set: `base.json`, `spacing/<dimension>.json`,
7
+ * `font/<textSize>.json`, `color/<brand>/<mode>/<surface>.json`;
8
+ * - `$themes.json` — one theme **group** per axis (`base`, `color`,
9
+ * `dimension`, `textSize`), each theme enabling its one set. Tokens Studio
10
+ * PRO turns each group into a Figma variable collection whose modes are the
11
+ * group's themes;
12
+ * - `$metadata.json` — `tokenSetOrder`.
13
+ *
14
+ * **Dialect** (measured in the 2026-08-13 spike — every mapping is a pure
15
+ * projection, no IR change): unprefixed `value`/`type`; colours as hex
16
+ * (8-digit when translucent); dimensions as `"<n>px|rem"` strings; aliases keep
17
+ * the shared `{group.path}` form; shadows as `boxShadow` objects. Types follow
18
+ * the Tokens Studio vocabulary, refined by path (`font.size` → `fontSizes`,
19
+ * `border.radius` → `borderRadius`, …). Line heights become percentages, the
20
+ * unit Tokens Studio reads as relative.
21
+ *
22
+ * **Carried from `$extensions`:** `com.apollion.font` letter spacing
23
+ * (`letterSpacing`, `em` → `%`) and text transforms (`textCase`). **Dropped**
24
+ * (no Tokens Studio type): `fontStyle`, the `circular: 100%` radius, the
25
+ * breakpoint interval notes. The deprecated `font.<dimension>` alias sets are
26
+ * not exported — no designer file pins them.
27
+ *
28
+ * Every set is reference-closed (see `ir.ts` slices), so a theme never needs a
29
+ * second set as a source.
30
+ *
31
+ * @see ../set-plan.ts · ../ir.ts · ./resolver.ts (the DTCG twin of this surface)
32
+ * @see packages/tokens/docs/spikes/2026-08-13-tokens-studio-export.md
33
+ */
34
+
35
+ import type { IRColorValue, IRDimensionValue, IRGroup, IRShadowValue, IRSlice, IRToken, IRValue } from '../ir';
36
+ import { isToken, kebab, kebabPath } from '../ir';
37
+ import type { ColorSetPlan, SetPlan } from '../set-plan';
38
+
39
+ /** Directory (relative to dist root) of the Tokens Studio tree. */
40
+ export const TOKENS_STUDIO_DIR = 'tokens-studio';
41
+
42
+ export interface TokensStudioShadow {
43
+ x: string;
44
+ y: string;
45
+ blur: string;
46
+ spread: string;
47
+ color: string;
48
+ type: 'dropShadow' | 'innerShadow';
49
+ }
50
+
51
+ export interface TokensStudioToken {
52
+ value: string | number | TokensStudioShadow;
53
+ type: string;
54
+ description?: string;
55
+ }
56
+
57
+ export interface TokensStudioGroup {
58
+ [key: string]: TokensStudioToken | TokensStudioGroup;
59
+ }
60
+
61
+ export type TokenSetStatus = 'enabled' | 'source' | 'disabled';
62
+
63
+ export interface TokensStudioTheme {
64
+ id: string;
65
+ name: string;
66
+ group: string;
67
+ selectedTokenSets: Record<string, TokenSetStatus>;
68
+ }
69
+
70
+ const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
71
+
72
+ /** Short decimal form — drops binary-float noise (`0.6000000000000001` → `0.6`). */
73
+ const num = (value: number) => `${Number(value.toFixed(4))}`;
74
+
75
+ const hexByte = (value: number) =>
76
+ Math.round(Math.min(1, Math.max(0, value)) * 255)
77
+ .toString(16)
78
+ .padStart(2, '0');
79
+
80
+ function color(value: IRColorValue): string {
81
+ return value.alpha !== undefined && value.alpha < 1 ? `${value.hex}${hexByte(value.alpha)}` : value.hex;
82
+ }
83
+
84
+ function dimension(value: IRDimensionValue): string {
85
+ return `${num(value.value)}${value.unit}`;
86
+ }
87
+
88
+ /** Shadow offsets are bare numbers in Tokens Studio (px implied). */
89
+ function shadowLength(value: IRDimensionValue): string {
90
+ return value.unit === 'px' ? num(value.value) : dimension(value);
91
+ }
92
+
93
+ function shadow(value: IRShadowValue): TokensStudioShadow {
94
+ const { shadow: layer } = value;
95
+ return {
96
+ x: shadowLength(layer.offsetX),
97
+ y: shadowLength(layer.offsetY),
98
+ blur: shadowLength(layer.blur),
99
+ spread: shadowLength(layer.spread),
100
+ color: color(layer.color),
101
+ type: layer.inset ? 'innerShadow' : 'dropShadow',
102
+ };
103
+ }
104
+
105
+ const startsWith = (path: string, ...prefixes: string[]) =>
106
+ prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}.`));
107
+
108
+ /** Tokens Studio type for an IR token at a kebab dotted `path`. */
109
+ export function tokensStudioType(token: IRToken, path: string): string {
110
+ switch (token.type) {
111
+ case 'color':
112
+ return 'color';
113
+ case 'dimension':
114
+ if (startsWith(path, 'font.size')) return 'fontSizes';
115
+ if (startsWith(path, 'border-width', 'border.width')) return 'borderWidth';
116
+ if (startsWith(path, 'border.radius')) return 'borderRadius';
117
+ if (startsWith(path, 'space', 'spacing')) return 'spacing';
118
+ if (startsWith(path, 'breakpoint', 'container-size', 'layout')) return 'sizing';
119
+ return 'dimension';
120
+ case 'fontFamily':
121
+ return 'fontFamilies';
122
+ case 'fontWeight':
123
+ return 'fontWeights';
124
+ case 'number':
125
+ return startsWith(path, 'font.line-height') ? 'lineHeights' : 'number';
126
+ case 'shadow':
127
+ return 'boxShadow';
128
+ default:
129
+ return 'other';
130
+ }
131
+ }
132
+
133
+ /** Tokens Studio value for an IR value; `type` picks the line-height form. */
134
+ export function tokensStudioValue(value: IRValue, type: string): TokensStudioToken['value'] {
135
+ switch (value.kind) {
136
+ case 'ref':
137
+ return `{${kebabPath(value.path)}}`;
138
+ case 'color':
139
+ return color(value);
140
+ case 'dimension':
141
+ return dimension(value);
142
+ case 'fontFamily':
143
+ return value.family;
144
+ case 'fontWeight':
145
+ return value.weight;
146
+ case 'number':
147
+ return type === 'lineHeights' ? `${num(value.number * 100)}%` : value.number;
148
+ case 'shadow':
149
+ return shadow(value);
150
+ case 'strokeStyle':
151
+ return value.style;
152
+ default:
153
+ return value.raw;
154
+ }
155
+ }
156
+
157
+ function description(token: IRToken): string | undefined {
158
+ if (token.deprecated === undefined || token.deprecated === false) return token.description;
159
+ const note = typeof token.deprecated === 'string' ? `Deprecated: ${token.deprecated}` : 'Deprecated.';
160
+ return token.description ? `${token.description} ${note}` : note;
161
+ }
162
+
163
+ function projectToken(token: IRToken, path: string): TokensStudioToken {
164
+ const type = tokensStudioType(token, path);
165
+ const out: TokensStudioToken = { value: tokensStudioValue(token.value, type), type };
166
+ const text = description(token);
167
+ if (text !== undefined) out.description = text;
168
+ return out;
169
+ }
170
+
171
+ /** Project an IR group to a Tokens Studio group (kebab keys, like the DTCG surface). */
172
+ export function projectTokensStudioGroup(group: IRGroup, prefix = ''): TokensStudioGroup {
173
+ const out: TokensStudioGroup = {};
174
+ for (const [key, node] of Object.entries(group)) {
175
+ const name = kebab(key);
176
+ const path = prefix ? `${prefix}.${name}` : name;
177
+ out[name] = isToken(node) ? projectToken(node, path) : projectTokensStudioGroup(node, path);
178
+ }
179
+ return out;
180
+ }
181
+
182
+ interface FontExtensions {
183
+ letterSpacing?: Record<string, string | number>;
184
+ textTransform?: Record<string, string>;
185
+ }
186
+
187
+ /** `-0.045em` → `-4.5%` (Tokens Studio's relative letter spacing); other units pass through. */
188
+ function letterSpacing(value: string | number): string {
189
+ if (typeof value === 'number') return `${num(value * 100)}%`;
190
+ const em = /^(-?[\d.]+)em$/.exec(value.trim());
191
+ return em ? `${num(Number(em[1]) * 100)}%` : value;
192
+ }
193
+
194
+ /** First-class tokens for the font extensions Tokens Studio can type. */
195
+ function fontExtensionTokens(extensions: FontExtensions): TokensStudioGroup {
196
+ const out: TokensStudioGroup = {};
197
+ if (extensions.letterSpacing) {
198
+ out['letter-spacing'] = Object.fromEntries(
199
+ Object.entries(extensions.letterSpacing).map(([key, value]) => [
200
+ kebab(key),
201
+ { value: letterSpacing(value), type: 'letterSpacing' },
202
+ ]),
203
+ );
204
+ }
205
+ if (extensions.textTransform) {
206
+ out['text-case'] = Object.fromEntries(
207
+ Object.entries(extensions.textTransform).map(([key, value]) => [kebab(key), { value, type: 'textCase' }]),
208
+ );
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** Render one Resolver slice as a Tokens Studio set document. */
214
+ export function renderTokensStudioSet(slice: IRSlice): string {
215
+ const doc = projectTokensStudioGroup(slice.groups);
216
+ const font = slice.extensions['com.apollion.font'] as FontExtensions | undefined;
217
+ if (font && doc.font) doc.font = { ...(doc.font as TokensStudioGroup), ...fontExtensionTokens(font) };
218
+ return json(doc);
219
+ }
220
+
221
+ /** Tokens Studio set name (and file stem) of a colour coordinate. */
222
+ export function tokensStudioColorSetName(set: Pick<ColorSetPlan, 'brand' | 'mode' | 'surface'>): string {
223
+ return `color/${set.brand}/${set.mode}/${set.surface}`;
224
+ }
225
+
226
+ /** Every set the Tokens Studio tree carries, in `tokenSetOrder` (deprecated font aliases excluded). */
227
+ export function planTokensStudioSets(plan: SetPlan): { name: string; group: string; theme: string }[] {
228
+ return [
229
+ { name: 'base', group: 'base', theme: 'base' },
230
+ ...plan.spacing.map((set) => ({ name: `spacing/${set.dimension}`, group: 'dimension', theme: set.dimension })),
231
+ ...plan.font
232
+ .filter((set) => !set.deprecatedDimension)
233
+ .map((set) => ({ name: `font/${set.textSize}`, group: 'textSize', theme: set.textSize })),
234
+ ...plan.color.map((set) => ({
235
+ name: tokensStudioColorSetName(set),
236
+ group: 'color',
237
+ theme: `${set.brand} / ${set.mode} / ${set.surface}`,
238
+ })),
239
+ ];
240
+ }
241
+
242
+ /** File path (relative to dist root) of a Tokens Studio set. */
243
+ export function tokensStudioSetFile(name: string): string {
244
+ return `${TOKENS_STUDIO_DIR}/${name}.json`;
245
+ }
246
+
247
+ /**
248
+ * `$themes.json` — one theme per set, grouped by axis. Theme ids are the set
249
+ * names with `/` → `-`, so they stay stable across builds.
250
+ */
251
+ export function renderTokensStudioThemes(plan: SetPlan): string {
252
+ const themes: TokensStudioTheme[] = planTokensStudioSets(plan).map((set) => ({
253
+ id: set.name.replace(/\//g, '-'),
254
+ name: set.theme,
255
+ group: set.group,
256
+ selectedTokenSets: { [set.name]: 'enabled' },
257
+ }));
258
+ return json(themes);
259
+ }
260
+
261
+ /** `$metadata.json` — the set order Tokens Studio loads. */
262
+ export function renderTokensStudioMetadata(plan: SetPlan): string {
263
+ return json({ tokenSetOrder: planTokensStudioSets(plan).map((set) => set.name) });
264
+ }