@mlola-ui/engine 1.0.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,286 @@
1
+ import { contrast, formatColor, parseColor, solveLightness } from "./color.mjs";
2
+
3
+ /**
4
+ * Palettes are derived, not curated.
5
+ *
6
+ * A spec gives a primary seed and the tint of the neutrals; this module builds
7
+ * every colour token for light and dark mode from them. Each text or fill
8
+ * pairing is solved to a WCAG target instead of being picked and then tested,
9
+ * so any seed a person or a model supplies produces an accessible palette.
10
+ *
11
+ * Roles, so a token is never asked to do two jobs:
12
+ * - `primary`, `success`, ... are fills; their `-foreground` sits on them.
13
+ * - `primary-text` is the brand colour solved for text on the page (links,
14
+ * focus rings). A vivid yellow stays a yellow button; its links go darker.
15
+ * - `success-text`, `danger-text`, ... are the status colours for text on
16
+ * the page, which a fill colour usually cannot be in both modes.
17
+ */
18
+
19
+ /** WCAG thresholds with a small margin, so rounding never drops below them. */
20
+ export const TARGETS = {
21
+ text: 17,
22
+ textDark: 17.5,
23
+ // Coloured text is solved with headroom, so it still clears AA on the
24
+ // hover fills and ~15% tints the library draws under it.
25
+ body: 5.3,
26
+ // Every text role is readable text. Faint is the quietest level, not a
27
+ // decorative one: it still clears AA, and muted sits clearly between.
28
+ muted: 6.8,
29
+ faint: 5.2,
30
+ control: 3.1,
31
+ borderLight: 1.33,
32
+ borderSubtleLight: 1.18,
33
+ borderDark: 1.5,
34
+ borderSubtleDark: 1.28,
35
+ separation: 1.55,
36
+ };
37
+
38
+ /** Of the page's backgrounds (including subtle panels), the one text has the least contrast against. */
39
+ function hardestBackground(foreground, neutrals) {
40
+ return [neutrals.background, neutrals.surface, neutrals.subtle].reduce((worst, candidate) =>
41
+ contrast(foreground, candidate) < contrast(foreground, worst) ? candidate : worst,
42
+ );
43
+ }
44
+
45
+ /** Solve a text colour against every page background it may sit on. */
46
+ function solveText(color, neutrals, target, direction) {
47
+ let solved = solveLightness(color, neutrals.background, target, direction);
48
+ solved = solveLightness(solved, hardestBackground(solved, neutrals), target, direction);
49
+ return solved;
50
+ }
51
+
52
+ const WHITE = { L: 0.99, C: 0, H: 0 };
53
+
54
+ const STATUS = {
55
+ success: { H: 150, C: 0.15, light: 0.52, dark: 0.72 },
56
+ warning: { H: 75, C: 0.16, light: 0.76, dark: 0.8 },
57
+ danger: { H: 25, C: 0.2, light: 0.56, dark: 0.7 },
58
+ info: { H: 250, C: 0.16, light: 0.54, dark: 0.74 },
59
+ };
60
+
61
+ /** The darkest ink a fill can carry, tinted toward the fill's own hue. */
62
+ const inkOn = (hue) => ({ L: 0.18, C: 0.02, H: hue });
63
+
64
+ /**
65
+ * Pick whichever of white or dark ink reads better on `fill`. If neither
66
+ * reaches the target, move the fill until the better one does.
67
+ */
68
+ function fillPair(fill, target, preferLight) {
69
+ let base = fill;
70
+ const dark = inkOn(fill.H);
71
+ const light = contrast(WHITE, base);
72
+ const deep = contrast(dark, base);
73
+ if (Math.max(light, deep) >= target) {
74
+ return { fill: base, foreground: light >= deep ? WHITE : dark };
75
+ }
76
+ if (preferLight ? deep > light : light >= deep) {
77
+ base = solveLightness(base, dark, target, "lighter");
78
+ return { fill: base, foreground: dark };
79
+ }
80
+ base = solveLightness(base, WHITE, target, "darker");
81
+ return { fill: base, foreground: WHITE };
82
+ }
83
+
84
+ const tint = (hue, chroma, L) => ({ L, C: chroma, H: hue });
85
+
86
+ function neutralsLight(spec) {
87
+ const { texture, depth } = spec.vector;
88
+ const paper = spec.color.neutral;
89
+ const ink = spec.color.ink ?? paper;
90
+ // Tactile themes sit on a tinted canvas with lighter cards; polished ones
91
+ // are flat, with cards separated by hairlines and shadow alone.
92
+ const surfaceL = 1 - 0.014 * texture;
93
+ const surface = tint(paper.hue, paper.chroma * 0.6, surfaceL);
94
+ const background = tint(paper.hue, paper.chroma, surfaceL - 0.02 * texture - 0.004 * depth * texture);
95
+ const elevated = tint(paper.hue, paper.chroma * 0.4, Math.min(1, surfaceL + 0.006));
96
+ const subtle = tint(paper.hue, paper.chroma * 1.2 + 0.001, background.L - 0.032);
97
+ const border = solveLightness(tint(paper.hue, paper.chroma * 1.3 + 0.002, 0.95), background, TARGETS.borderLight, "darker");
98
+ const borderSubtle = solveLightness(tint(paper.hue, paper.chroma + 0.002, 0.97), background, TARGETS.borderSubtleLight, "darker");
99
+ const text = solveLightness(tint(ink.hue, Math.max(ink.chroma, 0.006), 0.26), background, TARGETS.text, "darker");
100
+ const muted = solveLightness(tint(ink.hue, Math.max(ink.chroma * 1.5, 0.01), 0.56), subtle, TARGETS.muted, "darker");
101
+ const faint = solveLightness(tint(ink.hue, Math.max(ink.chroma, 0.008), 0.6), subtle, TARGETS.faint, "darker");
102
+ return { background, subtle, surface, elevated, border, borderSubtle, text, muted, faint };
103
+ }
104
+
105
+ function neutralsDark(spec) {
106
+ const { texture, depth } = spec.vector;
107
+ const paper = spec.color.neutral;
108
+ const ink = spec.color.ink ?? paper;
109
+ // Dark surfaces take the ink's hue: a warm paper theme reads as cool ink at
110
+ // night, the way printed matter does. Text takes the paper's.
111
+ const chroma = Math.max(ink.chroma * 1.2, 0.004);
112
+ const background = tint(ink.hue, chroma, 0.148 - 0.018 * texture);
113
+ const surface = tint(ink.hue, chroma, background.L + 0.025 + 0.02 * depth);
114
+ const elevated = tint(ink.hue, chroma * 1.1, surface.L + 0.035);
115
+ const subtle = tint(ink.hue, chroma * 1.1, background.L + 0.058);
116
+ const border = solveLightness(tint(ink.hue, chroma * 1.3, 0.25), background, TARGETS.borderDark, "lighter");
117
+ const borderSubtle = solveLightness(tint(ink.hue, chroma * 1.1, 0.2), background, TARGETS.borderSubtleDark, "lighter");
118
+ const text = solveLightness(tint(paper.hue, Math.min(paper.chroma, 0.01), 0.9), background, TARGETS.textDark, "lighter");
119
+ const lightest = elevated.L > subtle.L ? elevated : subtle;
120
+ const muted = solveLightness(tint(paper.hue, Math.max(paper.chroma * 1.4, 0.01), 0.66), lightest, TARGETS.muted, "lighter");
121
+ const faint = solveLightness(tint(paper.hue, Math.max(paper.chroma, 0.008), 0.5), lightest, TARGETS.faint, "lighter");
122
+ return { background, subtle, surface, elevated, border, borderSubtle, text, muted, faint };
123
+ }
124
+
125
+ const isAchromatic = (color) => color.C < 0.03;
126
+
127
+ function primaryFor(spec, mode, neutrals) {
128
+ const seedText = mode === "dark" ? spec.color.primaryDark ?? spec.color.primary : spec.color.primary;
129
+ const seed = parseColor(seedText) ?? { L: 0.21, C: 0.006, H: 286 };
130
+ let fill = { L: seed.L, C: seed.C, H: seed.H };
131
+ if (mode === "dark" && !spec.color.primaryDark) {
132
+ // Without an explicit night seed, a near-black brand inverts to near-white
133
+ // and a deep brand colour is lifted, so neither sinks into the page.
134
+ fill = isAchromatic(seed)
135
+ ? { L: neutrals.text.L, C: seed.C, H: seed.H }
136
+ : { L: Math.max(seed.L, 0.62), C: seed.C, H: seed.H };
137
+ }
138
+ // A fill keeps the brand's colour; it only has to stand apart from the page
139
+ // and carry its own label. Text contrast is primary-text's job.
140
+ if (contrast(fill, neutrals.background) < TARGETS.separation) {
141
+ fill = solveLightness(fill, neutrals.background, TARGETS.separation, mode === "light" ? "darker" : "lighter");
142
+ }
143
+ // WCAG 2's ratio favours dark ink on mid-tone saturated colours, but eyes read
144
+ // white on a violet, blue or red far better. In light mode a chromatic,
145
+ // non-warm brand darkens a little (at most 0.12 L) so white clears the
146
+ // target; yellows and oranges keep dark ink, as convention expects.
147
+ const warm = fill.H > 40 && fill.H < 130;
148
+ if (mode === "light" && !isAchromatic(fill) && !warm && contrast(WHITE, fill) < TARGETS.body) {
149
+ const deeper = solveLightness(fill, WHITE, TARGETS.body, "darker");
150
+ if (fill.L - deeper.L <= 0.12) fill = deeper;
151
+ }
152
+ const pair = fillPair(fill, TARGETS.body, mode === "dark");
153
+ const text = solveText({ L: seed.L, C: seed.C, H: seed.H }, neutrals, TARGETS.body, mode === "light" ? "darker" : "lighter");
154
+ const subtleChroma = isAchromatic(seed) ? Math.max(spec.color.neutral.chroma, 0.003) : Math.min(seed.C * 0.35, 0.06);
155
+ const subtle = mode === "light"
156
+ ? { L: neutrals.subtle.L + 0.01, C: subtleChroma, H: seed.H }
157
+ : { L: neutrals.subtle.L + 0.03, C: Math.min(subtleChroma * 1.2, 0.07), H: seed.H };
158
+ return { primary: pair.fill, foreground: pair.foreground, text, subtle };
159
+ }
160
+
161
+ function statusFor(name, mode, neutrals) {
162
+ const { H, C, light, dark } = STATUS[name];
163
+ const start = { L: mode === "light" ? light : dark, C, H };
164
+ const pair = fillPair(start, TARGETS.body, mode === "dark" || name === "warning");
165
+ const text = solveText(
166
+ { L: mode === "light" ? Math.min(light, 0.55) : Math.max(dark, 0.72), C, H },
167
+ neutrals,
168
+ TARGETS.body,
169
+ mode === "light" ? "darker" : "lighter",
170
+ );
171
+ return { fill: pair.fill, foreground: pair.foreground, text };
172
+ }
173
+
174
+ /**
175
+ * Six categorical colours for charts, apart from the status roles so a series
176
+ * never reads as "good" or "bad" by accident. The first follows the brand's
177
+ * hue (blue for a monochrome brand); the rest are the hues furthest from those
178
+ * already taken, so neighbours never share a family. Each is solved to 3:1
179
+ * against the surface (WCAG 1.4.11 for graphics).
180
+ */
181
+ const CHART_HUES = [255, 185, 300, 50, 10, 145];
182
+
183
+ function chartPalette(spec, mode, neutrals) {
184
+ const seed = parseColor(spec.color.primary) ?? { L: 0.21, C: 0.006, H: 286 };
185
+ const anchor = isAchromatic(seed) ? 255 : seed.H;
186
+ const distance = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
187
+ // The brand takes the place of the nearest hue, so there are always six.
188
+ const nearest = CHART_HUES.reduce((best, hue) => (distance(hue, anchor) < distance(best, anchor) ? hue : best));
189
+ const hues = [anchor];
190
+ const pool = CHART_HUES.filter((hue) => hue !== nearest);
191
+ while (hues.length < 6 && pool.length) {
192
+ pool.sort((a, b) => Math.min(...hues.map((hue) => distance(b, hue))) - Math.min(...hues.map((hue) => distance(a, hue))));
193
+ hues.push(pool.shift());
194
+ }
195
+ const chroma = isAchromatic(seed) ? 0.15 : Math.min(0.17, Math.max(0.11, seed.C));
196
+ return hues.map((hue) => {
197
+ // Warm hues read darker than they measure; they start lighter and richer so
198
+ // the contrast solve lands on orange, not brown.
199
+ const warm = hue > 30 && hue < 110;
200
+ const start = mode === "light" ? { L: warm ? 0.7 : 0.62, C: warm ? chroma + 0.03 : chroma, H: hue } : { L: 0.74, C: chroma * 0.92, H: hue };
201
+ return solveLightness(start, neutrals.surface, 3.05, mode === "light" ? "darker" : "lighter");
202
+ });
203
+ }
204
+
205
+ /** Every palette token for one mode, as serialised OKLCH strings. */
206
+ export function derivePalette(spec, mode = "light") {
207
+ const neutrals = mode === "dark" ? neutralsDark(spec) : neutralsLight(spec);
208
+ const primary = primaryFor(spec, mode, neutrals);
209
+ const palette = {
210
+ background: neutrals.background,
211
+ "background-subtle": neutrals.subtle,
212
+ surface: neutrals.surface,
213
+ "surface-elevated": neutrals.elevated,
214
+ text: neutrals.text,
215
+ "text-muted": neutrals.muted,
216
+ "text-faint": neutrals.faint,
217
+ border: neutrals.border,
218
+ "border-subtle": neutrals.borderSubtle,
219
+ primary: primary.primary,
220
+ "primary-foreground": primary.foreground,
221
+ "primary-text": primary.text,
222
+ "primary-subtle": primary.subtle,
223
+ };
224
+ for (const name of Object.keys(STATUS)) {
225
+ const status = statusFor(name, mode, neutrals);
226
+ palette[name] = status.fill;
227
+ palette[`${name}-foreground`] = status.foreground;
228
+ palette[`${name}-text`] = status.text;
229
+ }
230
+ chartPalette(spec, mode, neutrals).forEach((color, index) => {
231
+ palette[`chart-${index + 1}`] = color;
232
+ });
233
+ return Object.fromEntries(Object.entries(palette).map(([token, value]) => [token, formatColor(value)]));
234
+ }
235
+
236
+ /** The pairings the guarantee covers, as [foreground, background, minimum]. */
237
+ export const GUARANTEED_PAIRS = [
238
+ ["text", "background", 7],
239
+ ["text", "surface", 7],
240
+ ["text", "surface-elevated", 7],
241
+ ["text-muted", "background", 4.5],
242
+ ["text-muted", "surface", 4.5],
243
+ ["text-muted", "background-subtle", 4.5],
244
+ ["chart-1", "surface", 3],
245
+ ["chart-2", "surface", 3],
246
+ ["chart-3", "surface", 3],
247
+ ["chart-4", "surface", 3],
248
+ ["chart-5", "surface", 3],
249
+ ["chart-6", "surface", 3],
250
+ ["text-faint", "background", 4.5],
251
+ ["text-faint", "surface", 4.5],
252
+ ["text-faint", "background-subtle", 4.5],
253
+ ["primary-text", "background-subtle", 4.5],
254
+ ["success-text", "background-subtle", 4.5],
255
+ ["warning-text", "background-subtle", 4.5],
256
+ ["danger-text", "background-subtle", 4.5],
257
+ ["info-text", "background-subtle", 4.5],
258
+ ["primary", "background", 1.5],
259
+ ["primary-text", "background", 4.5],
260
+ ["primary-text", "surface", 4.5],
261
+ ["primary-foreground", "primary", 4.5],
262
+ ["success-foreground", "success", 4.5],
263
+ ["warning-foreground", "warning", 4.5],
264
+ ["danger-foreground", "danger", 4.5],
265
+ ["info-foreground", "info", 4.5],
266
+ ["success-text", "background", 4.5],
267
+ ["warning-text", "background", 4.5],
268
+ ["danger-text", "background", 4.5],
269
+ ["info-text", "background", 4.5],
270
+ ["success-text", "surface", 4.5],
271
+ ["warning-text", "surface", 4.5],
272
+ ["danger-text", "surface", 4.5],
273
+ ["info-text", "surface", 4.5],
274
+ ];
275
+
276
+ /** Measure a palette against the guarantee. Returns the failing pairs. */
277
+ export function auditPalette(palette) {
278
+ const failures = [];
279
+ for (const [foreground, background, minimum] of GUARANTEED_PAIRS) {
280
+ const ratio = contrast(palette[foreground], palette[background]);
281
+ if (ratio === null || ratio < minimum) {
282
+ failures.push({ foreground, background, minimum, ratio: ratio === null ? null : Number(ratio.toFixed(2)) });
283
+ }
284
+ }
285
+ return failures;
286
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,190 @@
1
+ import { toSrgb, parseColor } from "./color.mjs";
2
+ import { coupling, DEFAULT_THEME, derive, dimensions, profiles } from "./config.mjs";
3
+ import { readEngineCss, renderLibraryRecipesCss } from "./library-recipes.mjs";
4
+ import { derivePalette } from "./palette.mjs";
5
+ import { resolveFonts } from "./spec.mjs";
6
+ import { springEasing } from "./spring.mjs";
7
+ import { renderSpecCss } from "./theme-css.mjs";
8
+
9
+ const round = (value, places = 3) => Number(value.toFixed(places));
10
+
11
+ /**
12
+ * The one spacing and type scale every recipe draws from. Theme-invariant on
13
+ * purpose: theme varies radius, density of ink, motion and depth, while
14
+ * the rhythm of the grid stays constant so composed pages line up. Recipes must
15
+ * not invent values between these steps. Layers are the one stacking order
16
+ * for everything that floats: raised content, sticky headers, menus, dialogs,
17
+ * popovers, toasts and tooltips, and the few layers that sit above them all.
18
+ */
19
+ export function renderScaleCss() {
20
+ return `:root {
21
+ --ml-space-px: 1px;
22
+ --ml-space-0-5: 0.125rem;
23
+ --ml-space-1: 0.25rem;
24
+ --ml-space-1-5: 0.375rem;
25
+ --ml-space-2: 0.5rem;
26
+ --ml-space-2-5: 0.625rem;
27
+ --ml-space-3: 0.75rem;
28
+ --ml-space-3-5: 0.875rem;
29
+ --ml-space-4: 1rem;
30
+ --ml-space-4-5: 1.125rem;
31
+ --ml-space-5: 1.25rem;
32
+ --ml-space-6: 1.5rem;
33
+ --ml-space-7: 1.75rem;
34
+ --ml-space-8: 2rem;
35
+ --ml-space-9: 2.25rem;
36
+ --ml-space-10: 2.5rem;
37
+ --ml-space-12: 3rem;
38
+ --ml-space-14: 3.5rem;
39
+ --ml-space-16: 4rem;
40
+ --ml-type-2xs: 0.6875rem;
41
+ --ml-type-xs: 0.75rem;
42
+ --ml-type-sm: 0.8125rem;
43
+ --ml-type-base: 0.875rem;
44
+ --ml-type-md: 1rem;
45
+ --ml-type-lg: 1.125rem;
46
+ --ml-type-xl: 1.25rem;
47
+ --ml-type-2xl: 1.5rem;
48
+ --ml-leading-tight: 1.25;
49
+ --ml-leading-snug: 1.4;
50
+ --ml-leading-normal: 1.55;
51
+ --ml-layer-raised: 10;
52
+ --ml-layer-sticky: 20;
53
+ --ml-layer-header: 30;
54
+ --ml-layer-dropdown: 40;
55
+ --ml-layer-overlay: 50;
56
+ --ml-layer-modal: 51;
57
+ --ml-layer-popover: 55;
58
+ --ml-layer-toast: 60;
59
+ --ml-layer-tooltip: 60;
60
+ --ml-layer-top: 70;
61
+ }`;
62
+ }
63
+
64
+ /** Hex for the few browsers without OKLCH, derived from the default palette. */
65
+ function fallbackCss() {
66
+ const palette = derivePalette(profiles[DEFAULT_THEME].spec, "light");
67
+ const hex = (value) =>
68
+ `#${toSrgb(parseColor(value)).map((channel) => Math.round(channel * 255).toString(16).padStart(2, "0")).join("")}`;
69
+ const lines = Object.entries(palette).map(([token, value]) => ` --ml-${token}: ${hex(value)};`);
70
+ return `@supports not (color: oklch(0 0 0)) {
71
+ :root {
72
+ ${lines.join("\n")}
73
+ }
74
+ }`;
75
+ }
76
+
77
+ /**
78
+ * Every canonical theme, rendered from its spec through the same function a
79
+ * project theme or a Studio theme uses. The default theme also owns `:root`.
80
+ */
81
+ export function renderTokensCss() {
82
+ const themes = Object.entries(profiles).map(([id, profile]) =>
83
+ renderSpecCss(profile.spec, { root: id === DEFAULT_THEME, aliases: profile.aliases, banner: false }),
84
+ );
85
+ return `/* Generated by @mlola-ui/engine from the canonical theme specs. Do not hand edit. */
86
+ ${renderScaleCss()}
87
+
88
+ ${themes.join("\n")}
89
+ ${fallbackCss()}`;
90
+ }
91
+
92
+ export function renderFoundationsCss() {
93
+ return `/* Foundation layer. */\n${readEngineCss("foundations")}`;
94
+ }
95
+
96
+ export function renderMaterialsCss() {
97
+ return `/* Material recipes: apply a material to one surface regardless of theme. */\n${readEngineCss("materials")}`;
98
+ }
99
+
100
+ export function renderRecipesCss() {
101
+ return renderLibraryRecipesCss();
102
+ }
103
+
104
+ export function renderMotionCss() {
105
+ return `/* Motion and accessibility layer. */\n${readEngineCss("motion")}`;
106
+ }
107
+
108
+ export function renderDtcg() {
109
+ const dtcgColor = (value) => {
110
+ const match = value.match(/^oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\)$/);
111
+ if (!match) return value;
112
+ return {
113
+ colorSpace: "oklch",
114
+ components: match.slice(1, 4).map(Number),
115
+ alpha: match[4] === undefined ? 1 : Number(match[4]),
116
+ };
117
+ };
118
+ const output = {
119
+ $schema: "https://tr.designtokens.org/format/",
120
+ $description: "Generated Mlola UI theme tokens. Values use the DTCG 2025.10 format.",
121
+ theme: {
122
+ $description: "Theme vector dimensions normalized to [0,1].",
123
+ dimensions: Object.fromEntries(dimensions.map(([name, low, high], index) => [
124
+ name,
125
+ {
126
+ $type: "number",
127
+ $value: index,
128
+ $description: `${low} (0) → ${high} (1)`,
129
+ $extensions: { "com.mlola.axis": { low, high } },
130
+ },
131
+ ])),
132
+ coupling: Object.fromEntries(Object.entries(coupling).map(([name, value]) => [name, { $type: "number", $value: value }])),
133
+ },
134
+ profile: {},
135
+ };
136
+
137
+ for (const [name, profile] of Object.entries(profiles)) {
138
+ const derived = derive(profile);
139
+ const fonts = resolveFonts(profile.spec.fonts);
140
+ output.profile[name] = {
141
+ $description: `${profile.label} theme profile, derived from its spec.`,
142
+ $extensions: { "com.mlola.spec": profile.spec },
143
+ vector: {
144
+ $description: "Ordered τ vector is available in the com.mlola.vector extension.",
145
+ $extensions: { "com.mlola.vector": profile.vector },
146
+ ...Object.fromEntries(dimensions.map(([dimension], index) => [dimension, { $type: "number", $value: profile.vector[index] }])),
147
+ },
148
+ light: Object.fromEntries(Object.entries(derivePalette(profile.spec, "light")).map(([token, value]) => [token, { $type: "color", $value: dtcgColor(value) }])),
149
+ dark: Object.fromEntries(Object.entries(derivePalette(profile.spec, "dark")).map(([token, value]) => [token, { $type: "color", $value: dtcgColor(value) }])),
150
+ geometry: {
151
+ radius: { $type: "dimension", $value: { value: round(derived.radius), unit: "px" } },
152
+ border: { $type: "dimension", $value: { value: derived.border >= 1.4 ? 1.5 : 1, unit: "px" } },
153
+ control: { $type: "dimension", $value: { value: round(derived.controlHeight, 4), unit: "rem" } },
154
+ targetMin: { $type: "dimension", $value: { value: 44, unit: "px" } },
155
+ },
156
+ motion: {
157
+ fast: { $type: "duration", $value: { value: Math.round(derived.durationFast), unit: "ms" } },
158
+ normal: { $type: "duration", $value: { value: Math.round(derived.durationNormal), unit: "ms" } },
159
+ slow: { $type: "duration", $value: { value: Math.round(derived.durationSlow), unit: "ms" } },
160
+ reveal: { $type: "duration", $value: { value: Math.round(derived.durationReveal), unit: "ms" } },
161
+ easing: { $type: "cubicBezier", $value: [0.2, 0, 0, 1] },
162
+ $extensions: {
163
+ "com.mlola.spring": {
164
+ omega: round(derived.springOmega, 3),
165
+ damping: round(derived.springDamping, 3),
166
+ bounceDamping: round(derived.springBounceDamping, 3),
167
+ spring: springEasing({ omega: derived.springOmega, damping: derived.springDamping }),
168
+ bounce: springEasing(
169
+ { omega: derived.springOmega, damping: derived.springBounceDamping },
170
+ 28,
171
+ ),
172
+ },
173
+ },
174
+ },
175
+ typography: {
176
+ body: {
177
+ $type: "typography",
178
+ $value: {
179
+ fontFamily: (fonts.sans.match(/"([^"]+)"/)?.[1] ?? fonts.sans.split(",")[0]).trim(),
180
+ fontSize: { value: 16, unit: "px" },
181
+ fontWeight: 400,
182
+ letterSpacing: { value: round(derived.tracking, 4), unit: "em" },
183
+ lineHeight: round(derived.bodyLeading),
184
+ },
185
+ },
186
+ },
187
+ };
188
+ }
189
+ return output;
190
+ }