@cyclone-ui/theme-builder 0.0.0-stage → 0.0.4

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,1524 @@
1
+ /* -------------------------------------------------------------------
2
+
3
+ 🗲 Storm Software - Cyclone UI
4
+
5
+ This code was released as part of the Cyclone UI project. Cyclone UI
6
+ is maintained by Storm Software under the Apache-2.0 license, and is
7
+ free for commercial and private use. For more information, please visit
8
+ our licensing page at https://stormsoftware.com/licenses/projects/cyclone-ui.
9
+
10
+ Website: https://stormsoftware.com
11
+ Repository: https://github.com/storm-software/cyclone-ui
12
+ Documentation: https://docs.stormsoftware.com/projects/cyclone-ui
13
+ Contact: https://stormsoftware.com/contact
14
+
15
+ SPDX-License-Identifier: Apache-2.0
16
+
17
+ ------------------------------------------------------------------- */
18
+
19
+ import { definePlugin } from "@razorwind/core/plugin";
20
+ import type { PreprocessedTokens } from "style-dictionary/types";
21
+
22
+ const HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
23
+ const OKLCH_RE =
24
+ /^oklch\(\s*([0-9.]+%?|none)\s+([0-9.]+|none)\s+([0-9.]+|none)(?:\s*\/\s*([0-9.%]+))?\s*\)$/i;
25
+
26
+ const RESERVED_KEYS = new Set([
27
+ "$schema",
28
+ "$type",
29
+ "$description",
30
+ "$extensions",
31
+ "$extends",
32
+ "$deprecated",
33
+ "$root",
34
+ "$value",
35
+ "$ref",
36
+ "theme",
37
+ "palette",
38
+ "primitive"
39
+ ]);
40
+
41
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ function isTokenNode(value: unknown): value is Record<string, unknown> {
46
+ return isPlainObject(value) && ("$value" in value || "$ref" in value);
47
+ }
48
+
49
+ function isAlias(value: string): boolean {
50
+ const trimmed = value.trim();
51
+
52
+ return trimmed.startsWith("{") || trimmed.startsWith("var(");
53
+ }
54
+
55
+ function clamp01(value: number): number {
56
+ return Math.min(1, Math.max(0, value));
57
+ }
58
+
59
+ function toSrgb(channel: number): number {
60
+ const clipped = clamp01(channel);
61
+
62
+ return clipped <= 0.003_130_8
63
+ ? 12.92 * clipped
64
+ : 1.055 * clipped ** (1 / 2.4) - 0.055;
65
+ }
66
+
67
+ function fromSrgb(channel: number): number {
68
+ const clipped = clamp01(channel);
69
+
70
+ return clipped <= 0.040_45
71
+ ? clipped / 12.92
72
+ : ((clipped + 0.055) / 1.055) ** 2.4;
73
+ }
74
+
75
+ function hexChannel(channel: number): string {
76
+ return Math.round(toSrgb(channel) * 255)
77
+ .toString(16)
78
+ .padStart(2, "0");
79
+ }
80
+
81
+ function srgbHexChannel(channel: number): string {
82
+ return Math.round(clamp01(channel) * 255)
83
+ .toString(16)
84
+ .padStart(2, "0");
85
+ }
86
+
87
+ function alphaHex(alpha: number): string {
88
+ return Math.round(clamp01(alpha) * 255)
89
+ .toString(16)
90
+ .padStart(2, "0");
91
+ }
92
+
93
+ function expandHex(value: string): string {
94
+ const hex = value.startsWith("#") ? value.slice(1) : value;
95
+ if (hex.length === 3 || hex.length === 4) {
96
+ return `#${[...hex].map(digit => digit + digit).join("")}`;
97
+ }
98
+
99
+ return `#${hex}`;
100
+ }
101
+
102
+ /**
103
+ * Normalize a hex string or sRGB DTCG color object to `#rrggbb` / `#rrggbbaa`.
104
+ */
105
+ function colorValueToHex(value: unknown): string | undefined {
106
+ if (typeof value === "string") {
107
+ const trimmed = value.trim();
108
+ if (!HEX_RE.test(trimmed)) {
109
+ return undefined;
110
+ }
111
+
112
+ const expanded = expandHex(trimmed).toLowerCase();
113
+ if (expanded.length === 9 && expanded.endsWith("ff")) {
114
+ return expanded.slice(0, 7);
115
+ }
116
+
117
+ return expanded;
118
+ }
119
+
120
+ if (!isPlainObject(value)) {
121
+ return undefined;
122
+ }
123
+
124
+ if (typeof value.hex === "string" && HEX_RE.test(value.hex)) {
125
+ const hex = expandHex(value.hex).toLowerCase().slice(0, 7);
126
+ const alpha = parseAlpha(value.alpha);
127
+ if (alpha !== undefined && alpha < 1) {
128
+ return `${hex}${alphaHex(alpha)}`;
129
+ }
130
+
131
+ return hex;
132
+ }
133
+
134
+ if (
135
+ value.colorSpace === "srgb" &&
136
+ Array.isArray(value.components) &&
137
+ value.components.length >= 3
138
+ ) {
139
+ const red = parseComponent(value.components[0]);
140
+ const green = parseComponent(value.components[1]);
141
+ const blue = parseComponent(value.components[2]);
142
+ if (red === undefined || green === undefined || blue === undefined) {
143
+ return undefined;
144
+ }
145
+
146
+ const hex = `#${srgbHexChannel(red)}${srgbHexChannel(green)}${srgbHexChannel(blue)}`;
147
+ const alpha = parseAlpha(value.alpha);
148
+ if (alpha !== undefined && alpha < 1) {
149
+ return `${hex}${alphaHex(alpha)}`;
150
+ }
151
+
152
+ return hex;
153
+ }
154
+
155
+ return undefined;
156
+ }
157
+
158
+ /** Convert OKLCH (L C H) to unclipped linear sRGB channels. */
159
+ function oklchToLinearSrgb(
160
+ lightness: number,
161
+ chroma: number,
162
+ hue: number
163
+ ): { red: number; green: number; blue: number } {
164
+ const hueRad = (hue * Math.PI) / 180;
165
+ const a = chroma * Math.cos(hueRad);
166
+ const b = chroma * Math.sin(hueRad);
167
+
168
+ const l_ = lightness + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
169
+ const m_ = lightness - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
170
+ const s_ = lightness - 0.089_484_177_5 * a - 1.291_485_548 * b;
171
+
172
+ const l3 = l_ ** 3;
173
+ const m3 = m_ ** 3;
174
+ const s3 = s_ ** 3;
175
+
176
+ return {
177
+ red: 4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3,
178
+ green: -1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3,
179
+ blue: -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701 * s3
180
+ };
181
+ }
182
+
183
+ function oklchToHex(
184
+ lightness: number,
185
+ chroma: number,
186
+ hue: number,
187
+ alpha?: number
188
+ ): string {
189
+ const { red, green, blue } = oklchToLinearSrgb(lightness, chroma, hue);
190
+
191
+ const hex = `#${hexChannel(red)}${hexChannel(green)}${hexChannel(blue)}`;
192
+ if (alpha === undefined || alpha >= 1) {
193
+ return hex;
194
+ }
195
+
196
+ return `${hex}${alphaHex(alpha)}`;
197
+ }
198
+
199
+ function parseComponent(value: unknown): number | undefined {
200
+ if (typeof value === "number" && Number.isFinite(value)) {
201
+ return value;
202
+ }
203
+
204
+ if (typeof value !== "string" || value === "none") {
205
+ return undefined;
206
+ }
207
+
208
+ const parsed = Number.parseFloat(value);
209
+
210
+ return Number.isFinite(parsed) ? parsed : undefined;
211
+ }
212
+
213
+ function parseAlpha(value: unknown): number | undefined {
214
+ if (typeof value === "number" && Number.isFinite(value)) {
215
+ return value > 1 ? value / 100 : value;
216
+ }
217
+
218
+ if (typeof value !== "string") {
219
+ return undefined;
220
+ }
221
+
222
+ const parsed = Number.parseFloat(value);
223
+ if (!Number.isFinite(parsed)) {
224
+ return undefined;
225
+ }
226
+
227
+ return value.includes("%")
228
+ ? parsed / 100
229
+ : parsed > 1
230
+ ? parsed / 100
231
+ : parsed;
232
+ }
233
+
234
+ function oklchStringToHex(value: string): string | undefined {
235
+ const match = OKLCH_RE.exec(value.trim());
236
+ if (!match) {
237
+ return undefined;
238
+ }
239
+
240
+ let lightness = parseComponent(match[1]);
241
+ const chroma = parseComponent(match[2]);
242
+ const hue = parseComponent(match[3]);
243
+ if (lightness === undefined || chroma === undefined || hue === undefined) {
244
+ return undefined;
245
+ }
246
+
247
+ if (match[1]?.includes("%")) {
248
+ lightness /= 100;
249
+ }
250
+
251
+ return oklchToHex(lightness, chroma, hue, parseAlpha(match[4]));
252
+ }
253
+
254
+ function oklchObjectToHex(value: Record<string, unknown>): string | undefined {
255
+ if (!Array.isArray(value.components) || value.components.length < 3) {
256
+ return undefined;
257
+ }
258
+
259
+ let lightness = parseComponent(value.components[0]);
260
+ const chroma = parseComponent(value.components[1]);
261
+ const hue = parseComponent(value.components[2]);
262
+ if (lightness === undefined || chroma === undefined || hue === undefined) {
263
+ return undefined;
264
+ }
265
+
266
+ if (lightness > 1) {
267
+ lightness /= 100;
268
+ }
269
+
270
+ return oklchToHex(lightness, chroma, hue, parseAlpha(value.alpha));
271
+ }
272
+
273
+ function convertColor(value: unknown): string | undefined {
274
+ if (typeof value === "string") {
275
+ if (isAlias(value)) {
276
+ return undefined;
277
+ }
278
+
279
+ return oklchStringToHex(value) ?? colorValueToHex(value) ?? undefined;
280
+ }
281
+
282
+ if (!isPlainObject(value)) {
283
+ return undefined;
284
+ }
285
+
286
+ if (
287
+ typeof value.colorSpace === "string" &&
288
+ value.colorSpace.toLowerCase() === "oklch"
289
+ ) {
290
+ return oklchObjectToHex(value);
291
+ }
292
+
293
+ return colorValueToHex(value) ?? undefined;
294
+ }
295
+
296
+ function convertShadow(value: unknown): unknown {
297
+ if (Array.isArray(value)) {
298
+ return value.map(layer => convertShadow(layer));
299
+ }
300
+
301
+ if (!isPlainObject(value) || !("color" in value)) {
302
+ return value;
303
+ }
304
+
305
+ const color = convertColor(value.color);
306
+ if (!color) {
307
+ return value;
308
+ }
309
+
310
+ return { ...value, color };
311
+ }
312
+
313
+ function convertValue(value: unknown, type?: string): unknown {
314
+ if (type === "shadow") {
315
+ return convertShadow(value);
316
+ }
317
+
318
+ if (type === "border" && isPlainObject(value) && "color" in value) {
319
+ const color = convertColor(value.color);
320
+
321
+ return color ? { ...value, color } : value;
322
+ }
323
+
324
+ if (type === "color" || type === undefined) {
325
+ return convertColor(value) ?? value;
326
+ }
327
+
328
+ return value;
329
+ }
330
+
331
+ function resolveType(
332
+ token: Record<string, unknown>,
333
+ inheritedType?: string
334
+ ): string | undefined {
335
+ if (typeof token.$type === "string") {
336
+ return token.$type;
337
+ }
338
+
339
+ if (typeof token.type === "string") {
340
+ return token.type;
341
+ }
342
+
343
+ return inheritedType;
344
+ }
345
+
346
+ function mapTokenNode(
347
+ token: Record<string, unknown>,
348
+ inheritedType?: string
349
+ ): Record<string, unknown> {
350
+ if ("$ref" in token && !("$value" in token)) {
351
+ return { ...token };
352
+ }
353
+
354
+ const type = resolveType(token, inheritedType);
355
+ if (!("$value" in token)) {
356
+ return { ...token };
357
+ }
358
+
359
+ return {
360
+ ...token,
361
+ $value: convertValue(token.$value, type)
362
+ };
363
+ }
364
+
365
+ function walk(node: any, inheritedType?: string): unknown {
366
+ if (Array.isArray(node)) {
367
+ return node.map(item => walk(item, inheritedType));
368
+ }
369
+
370
+ if (!isPlainObject(node)) {
371
+ return node;
372
+ }
373
+
374
+ if (isTokenNode(node)) {
375
+ return mapTokenNode(node, inheritedType);
376
+ }
377
+
378
+ const groupType =
379
+ typeof (node as any).$type === "string"
380
+ ? (node as any).$type
381
+ : inheritedType;
382
+
383
+ const result: Record<string, unknown> = {};
384
+ for (const [key, value] of Object.entries(node)) {
385
+ if (RESERVED_KEYS.has(key) || key.startsWith("$")) {
386
+ result[key] = value;
387
+ continue;
388
+ }
389
+
390
+ result[key] = walk(value, groupType);
391
+ }
392
+
393
+ return result;
394
+ }
395
+
396
+ interface ColorStateVariant {
397
+ name: string;
398
+ opacity?: number;
399
+ /** Multiplier applied to OKLCH chroma for non-greyscale colors. */
400
+ saturation?: number;
401
+ /**
402
+ * Multiplier applied to OKLCH lightness (e.g. 1.1 = 10%). State direction
403
+ * is determined by the source color and its parent theme.
404
+ */
405
+ brightness?: number;
406
+ /** Resolve state colors from the parent theme's contrast primitive. */
407
+ useContrastPrimitive?: boolean;
408
+ /** Composite the color at `opacity` over this opaque color. */
409
+ fadeOver?: string;
410
+ }
411
+
412
+ type ParentTheme = "dark" | "light";
413
+
414
+ const CONTRAST_PRIMITIVES: Record<ParentTheme, string> = {
415
+ dark: "#ffffff",
416
+ light: "#0c0c0d"
417
+ };
418
+
419
+ function parentThemeFromDescription(
420
+ description: unknown
421
+ ): ParentTheme | undefined {
422
+ if (typeof description !== "string") {
423
+ return undefined;
424
+ }
425
+
426
+ const theme = /\b(dark|light)[\s-]+theme\b/i.exec(description)?.[1];
427
+
428
+ return theme?.toLowerCase() === "dark"
429
+ ? "dark"
430
+ : theme?.toLowerCase() === "light"
431
+ ? "light"
432
+ : undefined;
433
+ }
434
+
435
+ function parentThemeFromDictionary(
436
+ dictionary: unknown
437
+ ): ParentTheme | undefined {
438
+ if (!isPlainObject(dictionary) || !isPlainObject(dictionary.color)) {
439
+ return undefined;
440
+ }
441
+
442
+ return parentThemeFromDescription(dictionary.color.$description);
443
+ }
444
+
445
+ const LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
446
+ name: "active",
447
+ brightness: 1.45,
448
+ useContrastPrimitive: true
449
+ };
450
+
451
+ const COLOR_STATE_ACTIVE: ColorStateVariant = {
452
+ name: "active",
453
+ brightness: 1.25
454
+ };
455
+
456
+ const SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
457
+ name: "active",
458
+ brightness: 1.075
459
+ };
460
+
461
+ const SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
462
+ name: "active",
463
+ brightness: 1.125
464
+ };
465
+
466
+ const BORDER_COLOR_STATE_ACTIVE: ColorStateVariant = {
467
+ name: "active",
468
+ brightness: 1.4
469
+ };
470
+
471
+ const INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
472
+ name: "active",
473
+ brightness: 1.8,
474
+ useContrastPrimitive: true
475
+ };
476
+
477
+ const COLOR_STATE_INACTIVE: ColorStateVariant = {
478
+ name: "inactive",
479
+ brightness: 0.8
480
+ };
481
+
482
+ const LOW_SATURATION_COLOR_STATE_INACTIVE: ColorStateVariant = {
483
+ name: "inactive",
484
+ brightness: 0.6,
485
+ useContrastPrimitive: true
486
+ };
487
+
488
+ const COLOR_STATE_DISABLED: ColorStateVariant = {
489
+ name: "disabled",
490
+ opacity: 0.35,
491
+ saturation: 0.85,
492
+ fadeOver: "oklch(0.3839 0.0045 286.26)"
493
+ };
494
+
495
+ /** State variants for low-saturation (neutral) and saturated colors. */
496
+ interface ColorStateVariants {
497
+ lowSaturation: readonly ColorStateVariant[];
498
+ saturated: readonly ColorStateVariant[];
499
+ }
500
+
501
+ const DEFAULT_COLOR_STATE_VARIANTS: ColorStateVariants = {
502
+ lowSaturation: [
503
+ LOW_SATURATION_COLOR_STATE_ACTIVE,
504
+ LOW_SATURATION_COLOR_STATE_INACTIVE,
505
+ COLOR_STATE_DISABLED
506
+ ],
507
+ saturated: [COLOR_STATE_ACTIVE, COLOR_STATE_INACTIVE, COLOR_STATE_DISABLED]
508
+ };
509
+
510
+ const SUBTLE_COLOR_STATE_VARIANTS: ColorStateVariants = {
511
+ lowSaturation: [
512
+ SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE,
513
+ LOW_SATURATION_COLOR_STATE_INACTIVE,
514
+ COLOR_STATE_DISABLED
515
+ ],
516
+ saturated: DEFAULT_COLOR_STATE_VARIANTS.saturated
517
+ };
518
+
519
+ /**
520
+ * Surfaces shift their own lightness instead of jumping to the contrast
521
+ * primitive, so their states stay subtler than other low-saturation colors.
522
+ */
523
+ const SURFACE_COLOR_STATE_VARIANTS: ColorStateVariants = {
524
+ lowSaturation: [
525
+ SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE,
526
+ COLOR_STATE_INACTIVE,
527
+ COLOR_STATE_DISABLED
528
+ ],
529
+ saturated: DEFAULT_COLOR_STATE_VARIANTS.saturated
530
+ };
531
+
532
+ /**
533
+ * Status colors that receive generated `on-*` (text) and `*-subtle`
534
+ * (low-emphasis background) companions when missing.
535
+ */
536
+ const STATUS_COLOR_KEYS = [
537
+ "danger",
538
+ "warning",
539
+ "info",
540
+ "discovery",
541
+ "success"
542
+ ] as const;
543
+
544
+ /**
545
+ * Primary and tertiary colors are copied into the `base` theme as `base` and
546
+ * `base-subtle`, so it is generated like a status theme (`accent`, `active`,
547
+ * `subtle`, `onAccent`, `ring`, …). Rings and states are generated for these
548
+ * copies, not for primary, secondary, or tertiary.
549
+ */
550
+ const BASE_THEME_COLOR_NAMES: Record<string, string> = {
551
+ primary: "base",
552
+ tertiary: "base-subtle"
553
+ };
554
+
555
+ /**
556
+ * Colors that get their own Tamagui child theme, with generated companion,
557
+ * state, and focus ring tokens.
558
+ */
559
+ const THEME_COLOR_KEYS = ["base", ...STATUS_COLOR_KEYS] as const;
560
+
561
+ /** Every token in this group receives state variants. */
562
+ const COLOR_STATE_GROUP_KEY = "surface";
563
+
564
+ /** Standalone color tokens that receive state variants. */
565
+ const COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
566
+ border: {
567
+ lowSaturation: [
568
+ INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE,
569
+ LOW_SATURATION_COLOR_STATE_INACTIVE,
570
+ COLOR_STATE_DISABLED
571
+ ],
572
+ saturated: [
573
+ BORDER_COLOR_STATE_ACTIVE,
574
+ COLOR_STATE_INACTIVE,
575
+ COLOR_STATE_DISABLED
576
+ ]
577
+ },
578
+ ...Object.fromEntries(
579
+ THEME_COLOR_KEYS.flatMap(key => [
580
+ [key, DEFAULT_COLOR_STATE_VARIANTS],
581
+ [`${key}-subtle`, SUBTLE_COLOR_STATE_VARIANTS]
582
+ ])
583
+ )
584
+ };
585
+
586
+ /** OKLCH chroma below which a color uses the low-saturation state variants. */
587
+ const LOW_SATURATION_CHROMA = 0.02;
588
+
589
+ const LOW_SATURATION_RING_OPACITY = 0.2;
590
+ const SATURATED_RING_OPACITY = 0.15;
591
+ const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.25;
592
+ const MINIMUM_INVERSE_CONTRAST_RATIO = 2;
593
+ const SUBTLE_COLOR_LIGHTNESS_STEP = 0.01;
594
+ const MAX_SUBTLE_COLOR_ADJUSTMENTS = 100;
595
+ const SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS = 32;
596
+ /** OKLCH lightness shift for active colors already near the contrast primitive. */
597
+ const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.1;
598
+
599
+ function isStateVariantKey(key: string): boolean {
600
+ return (
601
+ key.toLowerCase().endsWith("-active") ||
602
+ key.toLowerCase().endsWith("-inactive") ||
603
+ key.toLowerCase().endsWith("-disabled")
604
+ );
605
+ }
606
+
607
+ function getTokenAtPath(
608
+ tree: unknown,
609
+ path: string
610
+ ): Record<string, unknown> | undefined {
611
+ let node: unknown = tree;
612
+
613
+ for (const part of path.split(".").filter(Boolean)) {
614
+ if (!isPlainObject(node) || !(part in node)) {
615
+ return undefined;
616
+ }
617
+
618
+ node = node[part];
619
+ }
620
+
621
+ return isTokenNode(node) ? node : undefined;
622
+ }
623
+
624
+ function resolveColorHex(
625
+ value: unknown,
626
+ tree: unknown,
627
+ seen: Set<string> = new Set()
628
+ ): string | undefined {
629
+ const direct = convertColor(value);
630
+ if (direct) {
631
+ return direct;
632
+ }
633
+
634
+ if (typeof value !== "string") {
635
+ return undefined;
636
+ }
637
+
638
+ const ref = /^\{([^{}]+)\}$/.exec(value.trim());
639
+ if (!ref?.[1] || seen.has(ref[1])) {
640
+ return undefined;
641
+ }
642
+
643
+ seen.add(ref[1]);
644
+ const token = getTokenAtPath(tree, ref[1]);
645
+ if (!token || !("$value" in token)) {
646
+ return undefined;
647
+ }
648
+
649
+ return resolveColorHex(token.$value, tree, seen);
650
+ }
651
+
652
+ function applyOpacity(hex: string, opacity: number): string {
653
+ const expanded = expandHex(hex).toLowerCase();
654
+ const rgb = expanded.slice(0, 7);
655
+ const existingAlpha =
656
+ expanded.length === 9 ? Number.parseInt(expanded.slice(7, 9), 16) / 255 : 1;
657
+ const alpha = clamp01(existingAlpha * opacity);
658
+
659
+ if (alpha >= 1) {
660
+ return rgb;
661
+ }
662
+
663
+ return `${rgb}${alphaHex(alpha)}`;
664
+ }
665
+
666
+ /** Alpha-composite `hex` at `opacity` over an opaque `background` (sRGB). */
667
+ function applyFadeOver(
668
+ hex: string,
669
+ background: string,
670
+ opacity: number
671
+ ): string {
672
+ const foreground = expandHex(hex).toLowerCase();
673
+ const base = expandHex(convertColor(background) ?? background).toLowerCase();
674
+ const existingAlpha =
675
+ foreground.length === 9
676
+ ? Number.parseInt(foreground.slice(7, 9), 16) / 255
677
+ : 1;
678
+ const alpha = clamp01(existingAlpha * opacity);
679
+ const channel = (offset: number) =>
680
+ srgbHexChannel(
681
+ (Number.parseInt(foreground.slice(offset, offset + 2), 16) * alpha +
682
+ Number.parseInt(base.slice(offset, offset + 2), 16) * (1 - alpha)) /
683
+ 255
684
+ );
685
+
686
+ return `#${channel(1)}${channel(3)}${channel(5)}`;
687
+ }
688
+
689
+ function applyRingOpacity(
690
+ node: unknown,
691
+ tree: unknown,
692
+ inRingGroup = false
693
+ ): unknown {
694
+ if (Array.isArray(node)) {
695
+ return node.map(item => applyRingOpacity(item, tree, inRingGroup));
696
+ }
697
+
698
+ if (!isPlainObject(node)) {
699
+ return node;
700
+ }
701
+
702
+ if (inRingGroup && isTokenNode(node) && resolveType(node) === "shadow") {
703
+ const value = node.$value;
704
+ const applyToLayer = (layer: unknown): unknown => {
705
+ if (!isPlainObject(layer) || !("color" in layer)) {
706
+ return layer;
707
+ }
708
+
709
+ const color = resolveColorHex(layer.color, tree);
710
+
711
+ return color
712
+ ? {
713
+ ...layer,
714
+ color: applyOpacity(
715
+ color,
716
+ isLowSaturation(color)
717
+ ? LOW_SATURATION_RING_OPACITY
718
+ : SATURATED_RING_OPACITY
719
+ )
720
+ }
721
+ : layer;
722
+ };
723
+
724
+ return {
725
+ ...node,
726
+ // CSS paints the first shadow layer on top. Ring tokens may use those
727
+ // leading layers as opaque offset masks, so only tint the final,
728
+ // visible ring layer.
729
+ $value: Array.isArray(value)
730
+ ? value.map((layer, index) =>
731
+ index === value.length - 1 ? applyToLayer(layer) : layer
732
+ )
733
+ : applyToLayer(value)
734
+ };
735
+ }
736
+
737
+ const result: Record<string, unknown> = {};
738
+ for (const [key, value] of Object.entries(node)) {
739
+ result[key] = applyRingOpacity(
740
+ value,
741
+ tree,
742
+ inRingGroup || key.toLowerCase() === "ring"
743
+ );
744
+ }
745
+
746
+ return result;
747
+ }
748
+
749
+ function ringLayer(color: string, spread: number): Record<string, unknown> {
750
+ const zero = { value: 0, unit: "px" };
751
+
752
+ return {
753
+ color,
754
+ offsetX: zero,
755
+ offsetY: zero,
756
+ blur: zero,
757
+ spread: { value: spread, unit: "px" }
758
+ };
759
+ }
760
+
761
+ /**
762
+ * Add the default, subtle, offset, and subtle-offset ring tokens for each
763
+ * ring color (e.g. `ring.danger`, `ring.danger-subtle`, `ring.danger-offset`,
764
+ * `ring.danger-subtle-offset`) unless they are already defined.
765
+ */
766
+ function addMissingRingTokens(dictionary: unknown): unknown {
767
+ if (!isPlainObject(dictionary) || !isPlainObject(dictionary.color)) {
768
+ return dictionary;
769
+ }
770
+
771
+ const color = dictionary.color;
772
+ const ring = isPlainObject(dictionary.ring) ? { ...dictionary.ring } : {};
773
+ let changed = false;
774
+
775
+ for (const name of THEME_COLOR_KEYS) {
776
+ if (!isTokenNode(color[name])) {
777
+ continue;
778
+ }
779
+
780
+ const ringColor = `{color.${name}}`;
781
+ const gap = ringLayer("{color.surface-elevated}", 3);
782
+ const variants: Record<string, [string, unknown[]]> = {
783
+ [name]: [`${name} 3px focus ring with no gap`, [ringLayer(ringColor, 3)]],
784
+ [`${name}-subtle`]: [
785
+ `${name} 1px hairline focus ring with no gap`,
786
+ [ringLayer(ringColor, 1)]
787
+ ],
788
+ [`${name}-offset`]: [
789
+ `${name} 3px focus ring with a 3px gap`,
790
+ [gap, ringLayer(ringColor, 6)]
791
+ ],
792
+ [`${name}-subtle-offset`]: [
793
+ `${name} 1px focus ring with a 3px gap`,
794
+ [gap, ringLayer(ringColor, 4)]
795
+ ]
796
+ };
797
+
798
+ for (const [key, [description, value]] of Object.entries(variants)) {
799
+ if (key in ring) {
800
+ continue;
801
+ }
802
+
803
+ ring[key] = {
804
+ $description: `Generated ${description}`,
805
+ $type: "shadow",
806
+ $value: value,
807
+ theme: colorTokenTheme(name)
808
+ };
809
+ changed = true;
810
+ }
811
+ }
812
+
813
+ return changed ? { ...dictionary, ring } : dictionary;
814
+ }
815
+
816
+ function isLowSaturation(hex: string): boolean {
817
+ return hexToOklch(hex).chroma < LOW_SATURATION_CHROMA;
818
+ }
819
+
820
+ function isGreyscale(hex: string): boolean {
821
+ const expanded = expandHex(hex).toLowerCase();
822
+
823
+ return (
824
+ expanded.slice(1, 3) === expanded.slice(3, 5) &&
825
+ expanded.slice(3, 5) === expanded.slice(5, 7)
826
+ );
827
+ }
828
+
829
+ /**
830
+ * Convert `#rrggbb` / `#rrggbbaa` to OKLCH so lightness can be scaled.
831
+ *
832
+ * @see https://bottosson.github.io/posts/oklab/
833
+ */
834
+ function hexToOklch(hex: string): {
835
+ lightness: number;
836
+ chroma: number;
837
+ hue: number;
838
+ alpha?: number;
839
+ } {
840
+ const expanded = expandHex(hex).toLowerCase();
841
+ const red = fromSrgb(Number.parseInt(expanded.slice(1, 3), 16) / 255);
842
+ const green = fromSrgb(Number.parseInt(expanded.slice(3, 5), 16) / 255);
843
+ const blue = fromSrgb(Number.parseInt(expanded.slice(5, 7), 16) / 255);
844
+ const alpha =
845
+ expanded.length === 9
846
+ ? Number.parseInt(expanded.slice(7, 9), 16) / 255
847
+ : undefined;
848
+
849
+ const l_ =
850
+ 0.412_221_470_8 * red + 0.536_332_536_3 * green + 0.051_445_992_9 * blue;
851
+ const m_ =
852
+ 0.211_903_498_2 * red + 0.680_699_545_1 * green + 0.107_396_956_6 * blue;
853
+ const s_ =
854
+ 0.088_302_461_9 * red + 0.281_718_837_6 * green + 0.629_978_700_5 * blue;
855
+
856
+ const l = Math.cbrt(l_);
857
+ const m = Math.cbrt(m_);
858
+ const s = Math.cbrt(s_);
859
+
860
+ const lightness =
861
+ 0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s;
862
+ const a = 1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s;
863
+ const b = 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s;
864
+
865
+ const chroma = Math.hypot(a, b);
866
+ let hue = (Math.atan2(b, a) * 180) / Math.PI;
867
+ if (hue < 0) {
868
+ hue += 360;
869
+ }
870
+
871
+ return { lightness, chroma, hue, alpha };
872
+ }
873
+
874
+ function isInSrgbGamut({
875
+ red,
876
+ green,
877
+ blue
878
+ }: {
879
+ red: number;
880
+ green: number;
881
+ blue: number;
882
+ }): boolean {
883
+ return (
884
+ red >= 0 && red <= 1 && green >= 0 && green <= 1 && blue >= 0 && blue <= 1
885
+ );
886
+ }
887
+
888
+ function oklchToGamutMappedHex(
889
+ lightness: number,
890
+ chroma: number,
891
+ hue: number,
892
+ alpha?: number
893
+ ): string {
894
+ if (isInSrgbGamut(oklchToLinearSrgb(lightness, chroma, hue))) {
895
+ return oklchToHex(lightness, chroma, hue, alpha);
896
+ }
897
+
898
+ let minimumChroma = 0;
899
+ let maximumChroma = chroma;
900
+
901
+ // Find the highest in-gamut chroma while holding lightness and hue fixed.
902
+ for (
903
+ let index = 0;
904
+ index < SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS;
905
+ index += 1
906
+ ) {
907
+ const candidateChroma = (minimumChroma + maximumChroma) / 2;
908
+ if (isInSrgbGamut(oklchToLinearSrgb(lightness, candidateChroma, hue))) {
909
+ minimumChroma = candidateChroma;
910
+ } else {
911
+ maximumChroma = candidateChroma;
912
+ }
913
+ }
914
+
915
+ return oklchToHex(lightness, minimumChroma, hue, alpha);
916
+ }
917
+
918
+ function applyBrightness(hex: string, factor: number): string {
919
+ const { lightness, chroma, hue, alpha } = hexToOklch(hex);
920
+
921
+ return oklchToHex(clamp01(lightness * factor), chroma, hue, alpha);
922
+ }
923
+
924
+ function applyLightnessShift(hex: string, shift: number): string {
925
+ const { lightness, chroma, hue, alpha } = hexToOklch(hex);
926
+
927
+ return oklchToHex(clamp01(lightness + shift), chroma, hue, alpha);
928
+ }
929
+
930
+ function applySaturation(hex: string, factor: number): string {
931
+ const { lightness, chroma, hue, alpha } = hexToOklch(hex);
932
+
933
+ return oklchToHex(lightness, chroma * clamp01(factor), hue, alpha);
934
+ }
935
+
936
+ function relativeLuminance(hex: string): number {
937
+ const expanded = expandHex(hex).toLowerCase();
938
+ const channel = (offset: number) => {
939
+ const value = Number.parseInt(expanded.slice(offset, offset + 2), 16) / 255;
940
+
941
+ return value <= 0.040_45 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
942
+ };
943
+
944
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
945
+ }
946
+
947
+ function contrastRatio(first: string, second: string): number {
948
+ const firstLuminance = relativeLuminance(first);
949
+ const secondLuminance = relativeLuminance(second);
950
+ const lighter = Math.max(firstLuminance, secondLuminance);
951
+ const darker = Math.min(firstLuminance, secondLuminance);
952
+
953
+ return (lighter + 0.05) / (darker + 0.05);
954
+ }
955
+
956
+ function createOnColor(hex: string): "#FAFAFA" | "#151518" {
957
+ const whiteContrast = contrastRatio(hex, "#FAFAFA");
958
+ if (whiteContrast >= MINIMUM_INVERSE_CONTRAST_RATIO) {
959
+ return "#FAFAFA";
960
+ }
961
+
962
+ const blackContrast = contrastRatio(hex, "#151518");
963
+ if (blackContrast * 0.5 > whiteContrast) {
964
+ return "#151518";
965
+ }
966
+
967
+ return "#FAFAFA";
968
+ }
969
+
970
+ function createMutedColor(
971
+ hex: string,
972
+ parentTheme: ParentTheme
973
+ ): { subtle: string; onMuted?: string } {
974
+ const { lightness, chroma, hue, alpha } = hexToOklch(hex);
975
+ const subtleDirection = parentTheme === "light" ? 1 : -1;
976
+ const onMutedDirection = -subtleDirection;
977
+ let subtleLightness = lightness;
978
+ let onMutedLightness = lightness;
979
+ let subtle = hex;
980
+ let onMuted = hex;
981
+
982
+ for (let index = 0; index < MAX_SUBTLE_COLOR_ADJUSTMENTS; index += 1) {
983
+ subtleLightness = clamp01(
984
+ subtleLightness + subtleDirection * SUBTLE_COLOR_LIGHTNESS_STEP
985
+ );
986
+ subtle = oklchToGamutMappedHex(subtleLightness, chroma, hue, alpha);
987
+ if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
988
+ return onMuted === hex ? { subtle } : { subtle, onMuted };
989
+ }
990
+
991
+ onMutedLightness = clamp01(
992
+ onMutedLightness + onMutedDirection * SUBTLE_COLOR_LIGHTNESS_STEP
993
+ );
994
+ onMuted = oklchToGamutMappedHex(onMutedLightness, chroma, hue, alpha);
995
+ if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
996
+ return { subtle, onMuted };
997
+ }
998
+
999
+ if (
1000
+ (subtleLightness === 0 || subtleLightness === 1) &&
1001
+ (onMutedLightness === 0 || onMutedLightness === 1)
1002
+ ) {
1003
+ break;
1004
+ }
1005
+ }
1006
+
1007
+ throw new Error(`Unable to generate an accessible subtle pair for ${hex}`);
1008
+ }
1009
+
1010
+ function isLightColor(hex: string, parentTheme?: ParentTheme): boolean {
1011
+ if (parentTheme === "dark") {
1012
+ return isWhiteColor(hex);
1013
+ }
1014
+
1015
+ if (parentTheme === "light") {
1016
+ return !isBlackColor(hex);
1017
+ }
1018
+
1019
+ return hexToOklch(hex).lightness >= 0.5;
1020
+ }
1021
+
1022
+ function isDarkColor(hex: string, parentTheme?: ParentTheme): boolean {
1023
+ return !isLightColor(hex, parentTheme);
1024
+ }
1025
+
1026
+ function isWhiteColor(hex: string): boolean {
1027
+ return hexToOklch(hex).lightness >= 0.75;
1028
+ }
1029
+
1030
+ function isBlackColor(hex: string): boolean {
1031
+ return hexToOklch(hex).lightness <= 0.25;
1032
+ }
1033
+
1034
+ /** Resolve a state multiplier from the source color and parent theme. */
1035
+ function directedBrightnessFactor(
1036
+ hex: string,
1037
+ factor: number,
1038
+ parentTheme?: ParentTheme
1039
+ ): number {
1040
+ return isDarkColor(hex, parentTheme) ? factor : 2 - factor;
1041
+ }
1042
+
1043
+ function contrastPrimitive(
1044
+ variant: ColorStateVariant,
1045
+ parentTheme?: ParentTheme
1046
+ ): string | undefined {
1047
+ return variant.useContrastPrimitive && parentTheme
1048
+ ? CONTRAST_PRIMITIVES[parentTheme]
1049
+ : undefined;
1050
+ }
1051
+
1052
+ /**
1053
+ * Active colors that already sit near the parent theme's contrast primitive
1054
+ * (near-white in dark themes, near-black in light themes) would barely change
1055
+ * by jumping to it, so their lightness shifts toward the middle instead.
1056
+ */
1057
+ function contrastSideLightnessShift(
1058
+ hex: string,
1059
+ variant: ColorStateVariant,
1060
+ parentTheme?: ParentTheme
1061
+ ): number | undefined {
1062
+ if (variant.name !== "active" || !contrastPrimitive(variant, parentTheme)) {
1063
+ return undefined;
1064
+ }
1065
+
1066
+ if (parentTheme === "dark" && isWhiteColor(hex)) {
1067
+ return -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1068
+ }
1069
+
1070
+ if (parentTheme === "light" && isBlackColor(hex)) {
1071
+ return CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1072
+ }
1073
+
1074
+ return undefined;
1075
+ }
1076
+
1077
+ function stateBrightnessFactor(
1078
+ hex: string,
1079
+ variant: ColorStateVariant,
1080
+ parentTheme?: ParentTheme
1081
+ ): number {
1082
+ if (variant.brightness === undefined) {
1083
+ return 1;
1084
+ }
1085
+
1086
+ if (contrastPrimitive(variant, parentTheme)) {
1087
+ return variant.name === "active" ? 1 : variant.brightness;
1088
+ }
1089
+
1090
+ return directedBrightnessFactor(hex, variant.brightness, parentTheme);
1091
+ }
1092
+
1093
+ function applyStateTransform(
1094
+ hex: string,
1095
+ variant: ColorStateVariant,
1096
+ parentTheme?: ParentTheme
1097
+ ): string {
1098
+ const shift = contrastSideLightnessShift(hex, variant, parentTheme);
1099
+ const primitive =
1100
+ shift === undefined ? contrastPrimitive(variant, parentTheme) : undefined;
1101
+ let transformed = primitive ?? hex;
1102
+
1103
+ if (shift !== undefined) {
1104
+ transformed = applyLightnessShift(hex, shift);
1105
+ } else if (
1106
+ variant.brightness !== undefined &&
1107
+ !(primitive && variant.name === "active")
1108
+ ) {
1109
+ transformed = applyBrightness(
1110
+ transformed,
1111
+ stateBrightnessFactor(transformed, variant, parentTheme)
1112
+ );
1113
+ }
1114
+
1115
+ if (variant.saturation !== undefined && !isGreyscale(hex)) {
1116
+ transformed = applySaturation(transformed, variant.saturation);
1117
+ if (variant.fadeOver === undefined) {
1118
+ return transformed;
1119
+ }
1120
+ }
1121
+
1122
+ if (variant.fadeOver !== undefined) {
1123
+ return applyFadeOver(transformed, variant.fadeOver, variant.opacity ?? 1);
1124
+ }
1125
+
1126
+ if (variant.opacity !== undefined) {
1127
+ return applyOpacity(transformed, variant.opacity);
1128
+ }
1129
+
1130
+ return transformed;
1131
+ }
1132
+
1133
+ function variantDetail(
1134
+ variant: ColorStateVariant,
1135
+ hex: string,
1136
+ parentTheme?: ParentTheme
1137
+ ): string {
1138
+ const details: string[] = [];
1139
+ const shift = contrastSideLightnessShift(hex, variant, parentTheme);
1140
+ const primitive = contrastPrimitive(variant, parentTheme);
1141
+
1142
+ if (shift !== undefined) {
1143
+ details.push(
1144
+ `${Math.round(Math.abs(shift) * 100)}% ${shift < 0 ? "darker" : "lighter"}`
1145
+ );
1146
+ } else if (primitive && variant.name === "active") {
1147
+ details.push(`${parentTheme} contrast primitive`);
1148
+ } else if (variant.brightness !== undefined) {
1149
+ const factor = stateBrightnessFactor(hex, variant, parentTheme);
1150
+ const percent = Math.round((factor - 1) * 100);
1151
+
1152
+ details.push(
1153
+ percent >= 0 ? `${percent}% brighter` : `${Math.abs(percent)}% darker`
1154
+ );
1155
+ }
1156
+
1157
+ if (variant.saturation !== undefined && !isGreyscale(hex)) {
1158
+ details.push(`${Math.round(variant.saturation * 100)}% saturation`);
1159
+ }
1160
+
1161
+ if (variant.fadeOver !== undefined) {
1162
+ details.push(
1163
+ `${Math.round((variant.opacity ?? 1) * 100)}% over ${variant.fadeOver}`
1164
+ );
1165
+ } else if (
1166
+ variant.opacity !== undefined &&
1167
+ (variant.saturation === undefined || isGreyscale(hex))
1168
+ ) {
1169
+ details.push(`${Math.round(variant.opacity * 100)}% opacity`);
1170
+ }
1171
+
1172
+ return details.join(", ") || variant.name;
1173
+ }
1174
+
1175
+ function createStateToken(
1176
+ source: Record<string, unknown>,
1177
+ hex: string,
1178
+ variant: ColorStateVariant,
1179
+ parentTheme?: ParentTheme
1180
+ ): Record<string, unknown> {
1181
+ const detail = variantDetail(variant, hex, parentTheme);
1182
+ const transformed = applyStateTransform(hex, variant, parentTheme);
1183
+
1184
+ return {
1185
+ ...source,
1186
+ $type: "color",
1187
+ $value: transformed,
1188
+ $description:
1189
+ typeof source.$description === "string"
1190
+ ? `${source.$description} (${variant.name}, ${detail})`
1191
+ : `${variant.name} state at ${detail}`
1192
+ };
1193
+ }
1194
+
1195
+ function addColorStateTokens(
1196
+ group: Record<string, unknown>,
1197
+ tree: unknown,
1198
+ variants: ColorStateVariants,
1199
+ onlyKey?: string,
1200
+ parentTheme?: ParentTheme
1201
+ ): Record<string, unknown> {
1202
+ const result = { ...group } as Record<string, any>;
1203
+ const names = onlyKey ? [onlyKey] : Object.keys(group);
1204
+
1205
+ for (const name of names) {
1206
+ if (name.startsWith("$") || isStateVariantKey(name)) {
1207
+ continue;
1208
+ }
1209
+
1210
+ const token = group[name];
1211
+ if (!isTokenNode(token)) {
1212
+ continue;
1213
+ }
1214
+
1215
+ const type = resolveType(token);
1216
+ if (type && type !== "color") {
1217
+ continue;
1218
+ }
1219
+
1220
+ const hex = resolveColorHex(token.$value, tree);
1221
+ if (!hex) {
1222
+ continue;
1223
+ }
1224
+
1225
+ const tokenVariants = isLowSaturation(hex)
1226
+ ? variants.lowSaturation
1227
+ : variants.saturated;
1228
+ const tokenParentTheme =
1229
+ parentThemeFromDescription(token.$description) ?? parentTheme;
1230
+
1231
+ for (const variant of tokenVariants) {
1232
+ const variantKey = `${name}-${variant.name}`;
1233
+ if (variantKey in result) {
1234
+ continue;
1235
+ }
1236
+
1237
+ result[variantKey] = createStateToken(
1238
+ token,
1239
+ hex,
1240
+ variant,
1241
+ tokenParentTheme
1242
+ );
1243
+ }
1244
+ }
1245
+
1246
+ return result;
1247
+ }
1248
+
1249
+ /**
1250
+ * Add an `on-*-accent` text color (renamed from `on-*` when defined), an
1251
+ * `*-accent` alias, a `*-subtle` low-emphasis background, and an `on-*-subtle`
1252
+ * text color for each theme color (`base` and the status colors) that does
1253
+ * not define them, so state variants can be generated for the subtle
1254
+ * backgrounds.
1255
+ */
1256
+ function addMissingStatusCompanionTokens(
1257
+ color: Record<string, unknown>,
1258
+ tree: unknown,
1259
+ parentTheme?: ParentTheme
1260
+ ): Record<string, unknown> {
1261
+ const result = { ...color };
1262
+ for (const name of THEME_COLOR_KEYS) {
1263
+ const token = color[name];
1264
+ if (!isTokenNode(token)) {
1265
+ continue;
1266
+ }
1267
+
1268
+ const type = resolveType(token);
1269
+ const hex = resolveColorHex(token.$value, tree);
1270
+ if ((type && type !== "color") || !hex) {
1271
+ continue;
1272
+ }
1273
+
1274
+ // Tamagui drops the theme name from child theme keys, so `danger` would be
1275
+ // empty and `on-danger` would be `on`. The `*-accent` names become `accent`
1276
+ // and `onAccent` in the `danger` theme; `on-danger` is renamed rather than
1277
+ // aliased so it does not leak into the root themes.
1278
+ const onToken = color[`on-${name}`];
1279
+ delete result[`on-${name}`];
1280
+ result[`on-${name}-accent`] ??= isTokenNode(onToken)
1281
+ ? { ...onToken, theme: name, group: name }
1282
+ : {
1283
+ ...token,
1284
+ $type: "color",
1285
+ $value: createOnColor(hex),
1286
+ $description: `Generated text and icon color on ${name} backgrounds`,
1287
+ theme: name,
1288
+ group: name
1289
+ };
1290
+
1291
+ result[`${name}-accent`] ??= {
1292
+ $type: "color",
1293
+ $value: `{color.${name}}`,
1294
+ $description: `Alias of ${name} for the ${name} theme`,
1295
+ theme: name,
1296
+ group: name
1297
+ };
1298
+
1299
+ let onMuted: string | undefined;
1300
+ if (parentTheme && !(`${name}-subtle` in color)) {
1301
+ const pair = createMutedColor(hex, parentTheme);
1302
+ result[`${name}-subtle`] = {
1303
+ ...token,
1304
+ $type: "color",
1305
+ $value: pair.subtle,
1306
+ $description: `Generated low-emphasis ${name} background for the ${parentTheme} theme`
1307
+ };
1308
+ onMuted = pair.onMuted ?? hex;
1309
+ } else if (isTokenNode(color[`${name}-subtle`])) {
1310
+ const subtleHex = resolveColorHex(
1311
+ (color[`${name}-subtle`] as Record<string, unknown>).$value,
1312
+ tree
1313
+ );
1314
+ onMuted = subtleHex && createOnColor(subtleHex);
1315
+ }
1316
+
1317
+ if (onMuted && !(`on-${name}-subtle` in color)) {
1318
+ result[`on-${name}-subtle`] = {
1319
+ ...token,
1320
+ $type: "color",
1321
+ $value: onMuted,
1322
+ $description: `Generated text and icon color on ${name}-subtle backgrounds`
1323
+ };
1324
+ }
1325
+ }
1326
+
1327
+ return result;
1328
+ }
1329
+
1330
+ /** Strip the `on-` prefix and `-subtle` / state suffixes from a token name. */
1331
+ function colorTokenRoot(name: string): string {
1332
+ return name
1333
+ .replace(/^on-/, "")
1334
+ .replace(/-(?:active|inactive|disabled)$/i, "")
1335
+ .replace(/-subtle$/, "");
1336
+ }
1337
+
1338
+ /**
1339
+ * Resolve the Tamagui theme for a color token name: the theme color name for
1340
+ * `base` and status colors (including their `on-*`, `*-subtle`, and state
1341
+ * variants). Primary, secondary, and tertiary colors stay in the root light
1342
+ * and dark themes so every child theme inherits them.
1343
+ */
1344
+ function colorTokenTheme(name: string): string | undefined {
1345
+ const root = colorTokenRoot(name);
1346
+
1347
+ return (THEME_COLOR_KEYS as readonly string[]).includes(root)
1348
+ ? root
1349
+ : undefined;
1350
+ }
1351
+
1352
+ /**
1353
+ * Copy each primary and secondary color (and its `on-*` color) into the
1354
+ * `base` theme as `base` / `base-subtle`.
1355
+ */
1356
+ function addBaseThemeAliases(
1357
+ color: Record<string, unknown>
1358
+ ): Record<string, unknown> {
1359
+ const result = { ...color };
1360
+ for (const [name, token] of Object.entries(color)) {
1361
+ const root = colorTokenRoot(name);
1362
+ const baseName = BASE_THEME_COLOR_NAMES[root];
1363
+ if (!baseName || !isTokenNode(token)) {
1364
+ continue;
1365
+ }
1366
+
1367
+ // Copied rather than referenced so states resolve inside nested
1368
+ // `light` / `dark` color groups.
1369
+ result[name.replace(root, baseName)] ??= {
1370
+ ...token,
1371
+ theme: "base",
1372
+ group: "base"
1373
+ };
1374
+ }
1375
+
1376
+ return result;
1377
+ }
1378
+
1379
+ /** Add a `theme` to each themed color token that does not define one. */
1380
+ function addColorTokenThemes(
1381
+ color: Record<string, unknown>
1382
+ ): Record<string, unknown> {
1383
+ const result = { ...color };
1384
+ for (const [name, token] of Object.entries(color)) {
1385
+ const theme = colorTokenTheme(name);
1386
+ if (theme && isTokenNode(token) && !("theme" in token)) {
1387
+ result[name] = { ...token, theme };
1388
+ }
1389
+ }
1390
+
1391
+ return result;
1392
+ }
1393
+
1394
+ /**
1395
+ * Add a `group` (the main token name) to each state, `on-*`, and `*-subtle`
1396
+ * color token that does not define one, e.g. `border-active` → `border`.
1397
+ */
1398
+ function addColorTokenGroups(
1399
+ group: Record<string, unknown>
1400
+ ): Record<string, unknown> {
1401
+ const result = { ...group };
1402
+ for (const [name, token] of Object.entries(group)) {
1403
+ const root = colorTokenRoot(name);
1404
+ if (root !== name && isTokenNode(token) && !("group" in token)) {
1405
+ result[name] = { ...token, group: root };
1406
+ }
1407
+ }
1408
+
1409
+ return result;
1410
+ }
1411
+
1412
+ function injectColorStateVariants(
1413
+ node: unknown,
1414
+ tree: unknown,
1415
+ key?: string,
1416
+ parentTheme?: ParentTheme
1417
+ ): unknown {
1418
+ if (Array.isArray(node)) {
1419
+ return node.map(item =>
1420
+ injectColorStateVariants(item, tree, undefined, parentTheme)
1421
+ );
1422
+ }
1423
+
1424
+ if (!isPlainObject(node) || isTokenNode(node)) {
1425
+ return node;
1426
+ }
1427
+
1428
+ const result: Record<string, unknown> = {};
1429
+ for (const [childKey, value] of Object.entries(node)) {
1430
+ if (RESERVED_KEYS.has(childKey) || childKey.startsWith("$")) {
1431
+ result[childKey] = value;
1432
+ continue;
1433
+ }
1434
+
1435
+ const childTheme =
1436
+ childKey === "dark" || childKey === "light" ? childKey : parentTheme;
1437
+ result[childKey] = injectColorStateVariants(
1438
+ value,
1439
+ tree,
1440
+ childKey,
1441
+ childTheme
1442
+ );
1443
+ }
1444
+
1445
+ if (key?.startsWith(`${COLOR_STATE_GROUP_KEY}-`)) {
1446
+ return addColorTokenGroups(
1447
+ addColorStateTokens(
1448
+ result,
1449
+ tree,
1450
+ SURFACE_COLOR_STATE_VARIANTS,
1451
+ undefined,
1452
+ parentTheme
1453
+ )
1454
+ );
1455
+ }
1456
+
1457
+ let withStates =
1458
+ key === "color"
1459
+ ? addMissingStatusCompanionTokens(
1460
+ addBaseThemeAliases(result),
1461
+ tree,
1462
+ parentTheme
1463
+ )
1464
+ : result;
1465
+ for (const [tokenKey, variants] of Object.entries(COLOR_STATE_VARIANTS)) {
1466
+ if (isTokenNode(withStates[tokenKey])) {
1467
+ withStates = addColorStateTokens(
1468
+ withStates,
1469
+ tree,
1470
+ variants,
1471
+ tokenKey,
1472
+ parentTheme
1473
+ );
1474
+ }
1475
+ }
1476
+
1477
+ return key === "color"
1478
+ ? addColorTokenGroups(addColorTokenThemes(withStates))
1479
+ : withStates;
1480
+ }
1481
+
1482
+ /**
1483
+ * Style Dictionary preprocessor: rewrite color `$value`s (and nested shadow
1484
+ * colors) to hex so generators emit `#rrggbb` / `#rrggbbaa` instead of
1485
+ * `oklch()`. Primary and secondary colors are first copied into the `base`
1486
+ * theme as `base` / `base-subtle`; primary, secondary, and tertiary themselves
1487
+ * stay in the root light and dark themes without rings or states. For `base`
1488
+ * and each status color, missing `on-*-accent` text (Tamagui `onAccent`; any
1489
+ * `on-*` color is renamed to it), `*-subtle` background, and `on-*-subtle`
1490
+ * text colors are generated next, along with a `*-accent` alias (Tamagui
1491
+ * `accent`), then active, inactive, and disabled variants are added to the
1492
+ * surface colors, border, and each theme color and its `*-subtle` color.
1493
+ * Low-saturation colors derive their states from the parent theme's contrast
1494
+ * primitive (surfaces use a subtler shift of their own lightness, and active
1495
+ * colors already near that primitive shift toward the middle); saturated
1496
+ * colors scale their own lightness. Theme colors get their name as theme,
1497
+ * unless one is already set. State, `on-*`, and `*-subtle` color tokens also
1498
+ * get their main token name as `group` (e.g. `border-active` → `border`).
1499
+ * Finally, focus rings are generated for `base` and each status color.
1500
+ */
1501
+ function tamaguiPreprocessor(
1502
+ dictionary: PreprocessedTokens
1503
+ ): PreprocessedTokens {
1504
+ const converted = walk(dictionary) as PreprocessedTokens;
1505
+ const withStateVariants = injectColorStateVariants(
1506
+ converted,
1507
+ converted,
1508
+ undefined,
1509
+ parentThemeFromDictionary(converted)
1510
+ ) as PreprocessedTokens;
1511
+ const withRings = addMissingRingTokens(withStateVariants);
1512
+
1513
+ return applyRingOpacity(withRings, withRings) as PreprocessedTokens;
1514
+ }
1515
+
1516
+ export default definePlugin(() => ({
1517
+ name: "cyclone-ui:tamagui-preprocessor",
1518
+ preprocessors: [
1519
+ {
1520
+ name: "tamagui-preprocessor",
1521
+ preprocessor: tamaguiPreprocessor
1522
+ }
1523
+ ]
1524
+ }));