@cyclone-ui/theme-builder 0.0.4 → 0.0.6

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.
@@ -416,6 +416,12 @@ const CONTRAST_PRIMITIVES: Record<ParentTheme, string> = {
416
416
  light: "#0c0c0d"
417
417
  };
418
418
 
419
+ /** Active colors already near the contrast primitive jump to these instead. */
420
+ const PURE_CONTRAST_COLORS: Record<ParentTheme, string> = {
421
+ dark: "#ffffff",
422
+ light: "#000000"
423
+ };
424
+
419
425
  function parentThemeFromDescription(
420
426
  description: unknown
421
427
  ): ParentTheme | undefined {
@@ -453,17 +459,12 @@ const COLOR_STATE_ACTIVE: ColorStateVariant = {
453
459
  brightness: 1.25
454
460
  };
455
461
 
456
- const SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
457
- name: "active",
458
- brightness: 1.075
459
- };
460
-
461
462
  const SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
462
463
  name: "active",
463
464
  brightness: 1.125
464
465
  };
465
466
 
466
- const BORDER_COLOR_STATE_ACTIVE: ColorStateVariant = {
467
+ const HAIRLINE_COLOR_STATE_ACTIVE: ColorStateVariant = {
467
468
  name: "active",
468
469
  brightness: 1.4
469
470
  };
@@ -517,23 +518,42 @@ const SUBTLE_COLOR_STATE_VARIANTS: ColorStateVariants = {
517
518
  };
518
519
 
519
520
  /**
520
- * Surfaces shift their own lightness instead of jumping to the contrast
521
- * primitive, so their states stay subtler than other low-saturation colors.
521
+ * Backgrounds shift their own lightness instead of jumping to the contrast
522
+ * primitive, at about half the change of other colors' states.
522
523
  */
523
- const SURFACE_COLOR_STATE_VARIANTS: ColorStateVariants = {
524
+ const BACKGROUND_COLOR_STATE_INACTIVE: ColorStateVariant = {
525
+ name: "inactive",
526
+ brightness: 0.9
527
+ };
528
+
529
+ const BACKGROUND_COLOR_STATE_DISABLED: ColorStateVariant = {
530
+ ...COLOR_STATE_DISABLED,
531
+ opacity: 0.675,
532
+ saturation: 0.925
533
+ };
534
+
535
+ const BACKGROUND_COLOR_STATE_VARIANTS: ColorStateVariants = {
524
536
  lowSaturation: [
525
- SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE,
526
- COLOR_STATE_INACTIVE,
527
- COLOR_STATE_DISABLED
537
+ { name: "active", brightness: 1.15 },
538
+ BACKGROUND_COLOR_STATE_INACTIVE,
539
+ BACKGROUND_COLOR_STATE_DISABLED
528
540
  ],
529
- saturated: DEFAULT_COLOR_STATE_VARIANTS.saturated
541
+ saturated: [
542
+ { name: "active", brightness: 1.25 },
543
+ BACKGROUND_COLOR_STATE_INACTIVE,
544
+ BACKGROUND_COLOR_STATE_DISABLED
545
+ ]
530
546
  };
531
547
 
532
548
  /**
533
- * Status colors that receive generated `on-*` (text) and `*-subtle`
534
- * (low-emphasis background) companions when missing.
549
+ * Color groups (`color.base`, `color.danger`, …) that get their own Tamagui
550
+ * child theme. Each defines a `primary` color and optionally a `secondary`
551
+ * (low-emphasis background); missing `secondary`, `on-primary`, and
552
+ * `on-secondary` companions are generated, along with state variants and
553
+ * focus rings.
535
554
  */
536
- const STATUS_COLOR_KEYS = [
555
+ const THEME_COLOR_KEYS = [
556
+ "base",
537
557
  "danger",
538
558
  "warning",
539
559
  "info",
@@ -541,63 +561,77 @@ const STATUS_COLOR_KEYS = [
541
561
  "success"
542
562
  ] as const;
543
563
 
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"
564
+ /** State variants for the tokens inside each theme color group. */
565
+ const THEME_COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
566
+ primary: DEFAULT_COLOR_STATE_VARIANTS,
567
+ secondary: SUBTLE_COLOR_STATE_VARIANTS,
568
+ link: DEFAULT_COLOR_STATE_VARIANTS
553
569
  };
554
570
 
555
571
  /**
556
- * Colors that get their own Tamagui child theme, with generated companion,
557
- * state, and focus ring tokens.
572
+ * Foreground and background hovered states are a smaller step than active
573
+ * (e.g. in dark themes, active is lighter than hovered).
558
574
  */
559
- const THEME_COLOR_KEYS = ["base", ...STATUS_COLOR_KEYS] as const;
575
+ const FOREGROUND_COLOR_STATE_VARIANTS: ColorStateVariants = {
576
+ lowSaturation: [
577
+ { name: "hovered", brightness: 1.3 },
578
+ ...DEFAULT_COLOR_STATE_VARIANTS.lowSaturation
579
+ ],
580
+ saturated: [
581
+ { name: "hovered", brightness: 1.15 },
582
+ ...DEFAULT_COLOR_STATE_VARIANTS.saturated
583
+ ]
584
+ };
585
+
586
+ const BACKGROUND_COLOR_STATE_HOVERED_VARIANTS: ColorStateVariants = {
587
+ lowSaturation: [
588
+ { name: "hovered", brightness: 1.1 },
589
+ ...BACKGROUND_COLOR_STATE_VARIANTS.lowSaturation
590
+ ],
591
+ saturated: [
592
+ { name: "hovered", brightness: 1.15 },
593
+ ...BACKGROUND_COLOR_STATE_VARIANTS.saturated
594
+ ]
595
+ };
560
596
 
561
- /** Every token in this group receives state variants. */
562
- const COLOR_STATE_GROUP_KEY = "surface";
597
+ /** Color tokens with these `group`s receive state variants. */
598
+ const COLOR_STATE_TOKEN_GROUPS: Record<string, ColorStateVariants> = {
599
+ foreground: FOREGROUND_COLOR_STATE_VARIANTS,
600
+ background: BACKGROUND_COLOR_STATE_HOVERED_VARIANTS
601
+ };
563
602
 
564
603
  /** Standalone color tokens that receive state variants. */
565
604
  const COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
566
- border: {
605
+ outline: {
567
606
  lowSaturation: [
568
607
  INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE,
569
608
  LOW_SATURATION_COLOR_STATE_INACTIVE,
570
609
  COLOR_STATE_DISABLED
571
610
  ],
572
611
  saturated: [
573
- BORDER_COLOR_STATE_ACTIVE,
612
+ HAIRLINE_COLOR_STATE_ACTIVE,
574
613
  COLOR_STATE_INACTIVE,
575
614
  COLOR_STATE_DISABLED
576
615
  ]
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
- )
616
+ }
584
617
  };
585
618
 
586
619
  /** OKLCH chroma below which a color uses the low-saturation state variants. */
587
620
  const LOW_SATURATION_CHROMA = 0.02;
588
621
 
589
- const LOW_SATURATION_RING_OPACITY = 0.2;
622
+ const LOW_SATURATION_RING_OPACITY = 0.1;
590
623
  const SATURATED_RING_OPACITY = 0.15;
591
- const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.25;
624
+ const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.5;
592
625
  const MINIMUM_INVERSE_CONTRAST_RATIO = 2;
593
626
  const SUBTLE_COLOR_LIGHTNESS_STEP = 0.01;
594
627
  const MAX_SUBTLE_COLOR_ADJUSTMENTS = 100;
595
628
  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;
629
+ /** OKLCH lightness shift for hovered colors already near the contrast primitive. */
630
+ const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.25;
598
631
 
599
632
  function isStateVariantKey(key: string): boolean {
600
633
  return (
634
+ key.toLowerCase().endsWith("-hovered") ||
601
635
  key.toLowerCase().endsWith("-active") ||
602
636
  key.toLowerCase().endsWith("-inactive") ||
603
637
  key.toLowerCase().endsWith("-disabled")
@@ -773,16 +807,17 @@ function addMissingRingTokens(dictionary: unknown): unknown {
773
807
  let changed = false;
774
808
 
775
809
  for (const name of THEME_COLOR_KEYS) {
776
- if (!isTokenNode(color[name])) {
810
+ const group = color[name];
811
+ if (!isPlainObject(group) || !isTokenNode(group.primary)) {
777
812
  continue;
778
813
  }
779
814
 
780
- const ringColor = `{color.${name}}`;
781
- const gap = ringLayer("{color.surface-elevated}", 3);
815
+ const ringColor = `{color.${name}.primary}`;
816
+ const gap = ringLayer("{color.elevated}", 3);
782
817
  const variants: Record<string, [string, unknown[]]> = {
783
818
  [name]: [`${name} 3px focus ring with no gap`, [ringLayer(ringColor, 3)]],
784
819
  [`${name}-subtle`]: [
785
- `${name} 1px hairline focus ring with no gap`,
820
+ `${name} 1px outline focus ring with no gap`,
786
821
  [ringLayer(ringColor, 1)]
787
822
  ],
788
823
  [`${name}-offset`]: [
@@ -804,7 +839,7 @@ function addMissingRingTokens(dictionary: unknown): unknown {
804
839
  $description: `Generated ${description}`,
805
840
  $type: "shadow",
806
841
  $value: value,
807
- theme: colorTokenTheme(name)
842
+ theme: name
808
843
  };
809
844
  changed = true;
810
845
  }
@@ -970,41 +1005,41 @@ function createOnColor(hex: string): "#FAFAFA" | "#151518" {
970
1005
  function createMutedColor(
971
1006
  hex: string,
972
1007
  parentTheme: ParentTheme
973
- ): { subtle: string; onMuted?: string } {
1008
+ ): { secondary: string; onMuted?: string } {
974
1009
  const { lightness, chroma, hue, alpha } = hexToOklch(hex);
975
- const subtleDirection = parentTheme === "light" ? 1 : -1;
976
- const onMutedDirection = -subtleDirection;
977
- let subtleLightness = lightness;
1010
+ const secondaryDirection = parentTheme === "light" ? 1 : -1;
1011
+ const onMutedDirection = -secondaryDirection;
1012
+ let secondaryLightness = lightness;
978
1013
  let onMutedLightness = lightness;
979
- let subtle = hex;
1014
+ let secondary = hex;
980
1015
  let onMuted = hex;
981
1016
 
982
1017
  for (let index = 0; index < MAX_SUBTLE_COLOR_ADJUSTMENTS; index += 1) {
983
- subtleLightness = clamp01(
984
- subtleLightness + subtleDirection * SUBTLE_COLOR_LIGHTNESS_STEP
1018
+ secondaryLightness = clamp01(
1019
+ secondaryLightness + secondaryDirection * SUBTLE_COLOR_LIGHTNESS_STEP
985
1020
  );
986
- subtle = oklchToGamutMappedHex(subtleLightness, chroma, hue, alpha);
987
- if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
988
- return onMuted === hex ? { subtle } : { subtle, onMuted };
1021
+ secondary = oklchToGamutMappedHex(secondaryLightness, chroma, hue, alpha);
1022
+ if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
1023
+ return onMuted === hex ? { secondary } : { secondary, onMuted };
989
1024
  }
990
1025
 
991
1026
  onMutedLightness = clamp01(
992
1027
  onMutedLightness + onMutedDirection * SUBTLE_COLOR_LIGHTNESS_STEP
993
1028
  );
994
1029
  onMuted = oklchToGamutMappedHex(onMutedLightness, chroma, hue, alpha);
995
- if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
996
- return { subtle, onMuted };
1030
+ if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
1031
+ return { secondary, onMuted };
997
1032
  }
998
1033
 
999
1034
  if (
1000
- (subtleLightness === 0 || subtleLightness === 1) &&
1035
+ (secondaryLightness === 0 || secondaryLightness === 1) &&
1001
1036
  (onMutedLightness === 0 || onMutedLightness === 1)
1002
1037
  ) {
1003
1038
  break;
1004
1039
  }
1005
1040
  }
1006
1041
 
1007
- throw new Error(`Unable to generate an accessible subtle pair for ${hex}`);
1042
+ throw new Error(`Unable to generate an accessible secondary pair for ${hex}`);
1008
1043
  }
1009
1044
 
1010
1045
  function isLightColor(hex: string, parentTheme?: ParentTheme): boolean {
@@ -1031,6 +1066,20 @@ function isBlackColor(hex: string): boolean {
1031
1066
  return hexToOklch(hex).lightness <= 0.25;
1032
1067
  }
1033
1068
 
1069
+ /** Hovered and active states share the contrast primitive behavior. */
1070
+ function isInteractionState(variant: ColorStateVariant): boolean {
1071
+ return variant.name === "hovered" || variant.name === "active";
1072
+ }
1073
+
1074
+ function isNearContrastPrimitive(
1075
+ hex: string,
1076
+ parentTheme?: ParentTheme
1077
+ ): boolean {
1078
+ return parentTheme === "dark"
1079
+ ? isWhiteColor(hex)
1080
+ : parentTheme === "light" && isBlackColor(hex);
1081
+ }
1082
+
1034
1083
  /** Resolve a state multiplier from the source color and parent theme. */
1035
1084
  function directedBrightnessFactor(
1036
1085
  hex: string,
@@ -1050,7 +1099,7 @@ function contrastPrimitive(
1050
1099
  }
1051
1100
 
1052
1101
  /**
1053
- * Active colors that already sit near the parent theme's contrast primitive
1102
+ * Hovered colors that already sit near the parent theme's contrast primitive
1054
1103
  * (near-white in dark themes, near-black in light themes) would barely change
1055
1104
  * by jumping to it, so their lightness shifts toward the middle instead.
1056
1105
  */
@@ -1059,19 +1108,37 @@ function contrastSideLightnessShift(
1059
1108
  variant: ColorStateVariant,
1060
1109
  parentTheme?: ParentTheme
1061
1110
  ): number | undefined {
1062
- if (variant.name !== "active" || !contrastPrimitive(variant, parentTheme)) {
1111
+ if (
1112
+ variant.name !== "hovered" ||
1113
+ !contrastPrimitive(variant, parentTheme) ||
1114
+ !isNearContrastPrimitive(hex, parentTheme)
1115
+ ) {
1063
1116
  return undefined;
1064
1117
  }
1065
1118
 
1066
- if (parentTheme === "dark" && isWhiteColor(hex)) {
1067
- return -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1068
- }
1119
+ return parentTheme === "dark"
1120
+ ? -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT
1121
+ : CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1122
+ }
1069
1123
 
1070
- if (parentTheme === "light" && isBlackColor(hex)) {
1071
- return CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1072
- }
1124
+ /**
1125
+ * The color an interaction state jumps to. Active colors already near the
1126
+ * contrast primitive go to pure white (dark) or pure black (light) so they
1127
+ * never lose contrast.
1128
+ */
1129
+ function stateContrastColor(
1130
+ hex: string,
1131
+ variant: ColorStateVariant,
1132
+ parentTheme?: ParentTheme
1133
+ ): string | undefined {
1134
+ const primitive = contrastPrimitive(variant, parentTheme);
1073
1135
 
1074
- return undefined;
1136
+ return primitive &&
1137
+ parentTheme &&
1138
+ variant.name === "active" &&
1139
+ isNearContrastPrimitive(hex, parentTheme)
1140
+ ? PURE_CONTRAST_COLORS[parentTheme]
1141
+ : primitive;
1075
1142
  }
1076
1143
 
1077
1144
  function stateBrightnessFactor(
@@ -1084,7 +1151,7 @@ function stateBrightnessFactor(
1084
1151
  }
1085
1152
 
1086
1153
  if (contrastPrimitive(variant, parentTheme)) {
1087
- return variant.name === "active" ? 1 : variant.brightness;
1154
+ return isInteractionState(variant) ? 1 : variant.brightness;
1088
1155
  }
1089
1156
 
1090
1157
  return directedBrightnessFactor(hex, variant.brightness, parentTheme);
@@ -1097,14 +1164,16 @@ function applyStateTransform(
1097
1164
  ): string {
1098
1165
  const shift = contrastSideLightnessShift(hex, variant, parentTheme);
1099
1166
  const primitive =
1100
- shift === undefined ? contrastPrimitive(variant, parentTheme) : undefined;
1167
+ shift === undefined
1168
+ ? stateContrastColor(hex, variant, parentTheme)
1169
+ : undefined;
1101
1170
  let transformed = primitive ?? hex;
1102
1171
 
1103
1172
  if (shift !== undefined) {
1104
1173
  transformed = applyLightnessShift(hex, shift);
1105
1174
  } else if (
1106
1175
  variant.brightness !== undefined &&
1107
- !(primitive && variant.name === "active")
1176
+ !(primitive && isInteractionState(variant))
1108
1177
  ) {
1109
1178
  transformed = applyBrightness(
1110
1179
  transformed,
@@ -1137,14 +1206,18 @@ function variantDetail(
1137
1206
  ): string {
1138
1207
  const details: string[] = [];
1139
1208
  const shift = contrastSideLightnessShift(hex, variant, parentTheme);
1140
- const primitive = contrastPrimitive(variant, parentTheme);
1209
+ const primitive = stateContrastColor(hex, variant, parentTheme);
1141
1210
 
1142
1211
  if (shift !== undefined) {
1143
1212
  details.push(
1144
1213
  `${Math.round(Math.abs(shift) * 100)}% ${shift < 0 ? "darker" : "lighter"}`
1145
1214
  );
1146
- } else if (primitive && variant.name === "active") {
1147
- details.push(`${parentTheme} contrast primitive`);
1215
+ } else if (primitive && isInteractionState(variant)) {
1216
+ details.push(
1217
+ parentTheme && primitive !== CONTRAST_PRIMITIVES[parentTheme]
1218
+ ? `pure ${parentTheme === "dark" ? "white" : "black"}`
1219
+ : `${parentTheme} contrast primitive`
1220
+ );
1148
1221
  } else if (variant.brightness !== undefined) {
1149
1222
  const factor = stateBrightnessFactor(hex, variant, parentTheme);
1150
1223
  const percent = Math.round((factor - 1) * 100);
@@ -1222,9 +1295,16 @@ function addColorStateTokens(
1222
1295
  continue;
1223
1296
  }
1224
1297
 
1225
- const tokenVariants = isLowSaturation(hex)
1298
+ // Hovered states default to the matching active variant.
1299
+ const baseVariants = isLowSaturation(hex)
1226
1300
  ? variants.lowSaturation
1227
1301
  : variants.saturated;
1302
+ const hasHovered = baseVariants.some(variant => variant.name === "hovered");
1303
+ const tokenVariants = baseVariants.flatMap(variant =>
1304
+ variant.name === "active" && !hasHovered
1305
+ ? [{ ...variant, name: "hovered" }, variant]
1306
+ : [variant]
1307
+ );
1228
1308
  const tokenParentTheme =
1229
1309
  parentThemeFromDescription(token.$description) ?? parentTheme;
1230
1310
 
@@ -1247,144 +1327,71 @@ function addColorStateTokens(
1247
1327
  }
1248
1328
 
1249
1329
  /**
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.
1330
+ * Fill in a theme color group (`color.danger`, …): generate a missing
1331
+ * `secondary` low-emphasis background from `primary` and the parent theme,
1332
+ * a missing `link` color (aliasing `primary`), `on-primary` / `on-secondary`
1333
+ * text colors, and state variants for `primary`, `secondary` and `link`. Every token in the group gets the group name as
1334
+ * its `theme` and `group`, so Tamagui places it in that child theme (e.g.
1335
+ * `danger.on-primary` → `onPrimary` in the `danger` theme).
1255
1336
  */
1256
- function addMissingStatusCompanionTokens(
1257
- color: Record<string, unknown>,
1337
+ function addThemeColorGroupTokens(
1338
+ name: string,
1339
+ group: Record<string, unknown>,
1258
1340
  tree: unknown,
1259
1341
  parentTheme?: ParentTheme
1260
1342
  ): 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
- }
1343
+ const token = group.primary;
1344
+ const hex = isTokenNode(token) && resolveColorHex(token.$value, tree);
1345
+ const type = isTokenNode(token) && resolveType(token);
1346
+ if (!isTokenNode(token) || !hex || (type && type !== "color")) {
1347
+ return group;
1348
+ }
1267
1349
 
1268
- const type = resolveType(token);
1269
- const hex = resolveColorHex(token.$value, tree);
1270
- if ((type && type !== "color") || !hex) {
1271
- continue;
1272
- }
1350
+ let result: Record<string, unknown> = { ...group };
1351
+ result.link ??= {
1352
+ ...token,
1353
+ $type: "color",
1354
+ $value: `{color.${name}.primary}`,
1355
+ $description: `Generated ${name} hyperlink text color (matches primary)`
1356
+ };
1357
+ result["on-primary"] ??= {
1358
+ ...token,
1359
+ $type: "color",
1360
+ $value: createOnColor(hex),
1361
+ $description: `Generated text and icon color on ${name} primary backgrounds`
1362
+ };
1273
1363
 
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`] ??= {
1364
+ let onSecondary: string | undefined;
1365
+ if (parentTheme && !("secondary" in group)) {
1366
+ const pair = createMutedColor(hex, parentTheme);
1367
+ result.secondary = {
1368
+ ...token,
1292
1369
  $type: "color",
1293
- $value: `{color.${name}}`,
1294
- $description: `Alias of ${name} for the ${name} theme`,
1295
- theme: name,
1296
- group: name
1370
+ $value: pair.secondary,
1371
+ $description: `Generated low-emphasis ${name} background for the ${parentTheme} theme`
1297
1372
  };
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
- }
1373
+ onSecondary = pair.onMuted ?? hex;
1374
+ } else if (isTokenNode(group.secondary)) {
1375
+ const secondaryHex = resolveColorHex(group.secondary.$value, tree);
1376
+ onSecondary = secondaryHex && createOnColor(secondaryHex);
1325
1377
  }
1326
1378
 
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)] ??= {
1379
+ if (onSecondary && !("on-secondary" in group)) {
1380
+ result["on-secondary"] = {
1370
1381
  ...token,
1371
- theme: "base",
1372
- group: "base"
1382
+ $type: "color",
1383
+ $value: onSecondary,
1384
+ $description: `Generated text and icon color on ${name} secondary backgrounds`
1373
1385
  };
1374
1386
  }
1375
1387
 
1376
- return result;
1377
- }
1388
+ for (const [key, variants] of Object.entries(THEME_COLOR_STATE_VARIANTS)) {
1389
+ result = addColorStateTokens(result, tree, variants, key, parentTheme);
1390
+ }
1378
1391
 
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 };
1392
+ for (const [key, value] of Object.entries(result)) {
1393
+ if (isTokenNode(value)) {
1394
+ result[key] = { theme: name, group: name, ...value };
1388
1395
  }
1389
1396
  }
1390
1397
 
@@ -1392,15 +1399,17 @@ function addColorTokenThemes(
1392
1399
  }
1393
1400
 
1394
1401
  /**
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`.
1402
+ * Add a `group` (the main token name) to each state and `on-*` color token
1403
+ * that does not define one, e.g. `outline-active` → `outline`.
1397
1404
  */
1398
1405
  function addColorTokenGroups(
1399
1406
  group: Record<string, unknown>
1400
1407
  ): Record<string, unknown> {
1401
1408
  const result = { ...group };
1402
1409
  for (const [name, token] of Object.entries(group)) {
1403
- const root = colorTokenRoot(name);
1410
+ const root = name
1411
+ .replace(/^on-/, "")
1412
+ .replace(/-(?:hovered|active|inactive|disabled)$/i, "");
1404
1413
  if (root !== name && isTokenNode(token) && !("group" in token)) {
1405
1414
  result[name] = { ...token, group: root };
1406
1415
  }
@@ -1442,26 +1451,37 @@ function injectColorStateVariants(
1442
1451
  );
1443
1452
  }
1444
1453
 
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
- }
1454
+ let withStates = result;
1455
+ if (key === "color") {
1456
+ for (const name of THEME_COLOR_KEYS) {
1457
+ const group = withStates[name];
1458
+ if (isPlainObject(group) && !isTokenNode(group)) {
1459
+ withStates[name] = addThemeColorGroupTokens(
1460
+ name,
1461
+ group,
1462
+ tree,
1463
+ parentTheme
1464
+ );
1465
+ }
1466
+ }
1456
1467
 
1457
- let withStates =
1458
- key === "color"
1459
- ? addMissingStatusCompanionTokens(
1460
- addBaseThemeAliases(result),
1468
+ for (const [tokenKey, token] of Object.entries(withStates)) {
1469
+ const variants =
1470
+ isTokenNode(token) && typeof token.group === "string"
1471
+ ? COLOR_STATE_TOKEN_GROUPS[token.group]
1472
+ : undefined;
1473
+ if (variants) {
1474
+ withStates = addColorStateTokens(
1475
+ withStates,
1461
1476
  tree,
1477
+ variants,
1478
+ tokenKey,
1462
1479
  parentTheme
1463
- )
1464
- : result;
1480
+ );
1481
+ }
1482
+ }
1483
+ }
1484
+
1465
1485
  for (const [tokenKey, variants] of Object.entries(COLOR_STATE_VARIANTS)) {
1466
1486
  if (isTokenNode(withStates[tokenKey])) {
1467
1487
  withStates = addColorStateTokens(
@@ -1474,31 +1494,31 @@ function injectColorStateVariants(
1474
1494
  }
1475
1495
  }
1476
1496
 
1477
- return key === "color"
1478
- ? addColorTokenGroups(addColorTokenThemes(withStates))
1479
- : withStates;
1497
+ return key === "color" ? addColorTokenGroups(withStates) : withStates;
1480
1498
  }
1481
1499
 
1482
1500
  /**
1483
1501
  * Style Dictionary preprocessor: rewrite color `$value`s (and nested shadow
1484
1502
  * 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.
1503
+ * `oklch()`. Root colors (emphasis, ink, subtle, subtlest, outline, backgrounds)
1504
+ * stay in the root light and dark themes without rings. For the `base` group and each
1505
+ * status group, a missing `secondary` background and `on-primary` /
1506
+ * `on-secondary` text colors are generated, then hovered, active, inactive,
1507
+ * and disabled variants are added to the outline, `foreground` and
1508
+ * `background` group colors, and each theme color group's `primary`, `secondary`
1509
+ * and `link` (which defaults to `primary` when missing).
1493
1510
  * 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`).
1511
+ * primitive (backgrounds use a half-strength shift of their own lightness; hovered
1512
+ * colors already near that primitive shift toward the middle, while active
1513
+ * ones become pure white or black); saturated
1514
+ * colors scale their own lightness. Foreground and background hovered states
1515
+ * take a smaller step than their active states. Tokens in a theme color group get the
1516
+ * group name as `theme` and `group`, unless already set; root state and `on-*`
1517
+ * tokens get their main token name as `group` (e.g. `outline-active` →
1518
+ * `outline`).
1499
1519
  * Finally, focus rings are generated for `base` and each status color.
1500
1520
  */
1501
- function tamaguiPreprocessor(
1521
+ export function tamaguiPreprocessor(
1502
1522
  dictionary: PreprocessedTokens
1503
1523
  ): PreprocessedTokens {
1504
1524
  const converted = walk(dictionary) as PreprocessedTokens;