@cyclone-ui/theme-builder 0.0.4 → 0.0.5

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.0375 },
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.125 },
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,50 @@ 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
553
568
  };
554
569
 
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";
570
+ /** Color tokens with these `group`s receive state variants. */
571
+ const COLOR_STATE_TOKEN_GROUPS: Record<string, ColorStateVariants> = {
572
+ foreground: DEFAULT_COLOR_STATE_VARIANTS,
573
+ background: BACKGROUND_COLOR_STATE_VARIANTS
574
+ };
563
575
 
564
576
  /** Standalone color tokens that receive state variants. */
565
577
  const COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
566
- border: {
578
+ hairline: {
567
579
  lowSaturation: [
568
580
  INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE,
569
581
  LOW_SATURATION_COLOR_STATE_INACTIVE,
570
582
  COLOR_STATE_DISABLED
571
583
  ],
572
584
  saturated: [
573
- BORDER_COLOR_STATE_ACTIVE,
585
+ HAIRLINE_COLOR_STATE_ACTIVE,
574
586
  COLOR_STATE_INACTIVE,
575
587
  COLOR_STATE_DISABLED
576
588
  ]
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
- )
589
+ }
584
590
  };
585
591
 
586
592
  /** OKLCH chroma below which a color uses the low-saturation state variants. */
587
593
  const LOW_SATURATION_CHROMA = 0.02;
588
594
 
589
- const LOW_SATURATION_RING_OPACITY = 0.2;
595
+ const LOW_SATURATION_RING_OPACITY = 0.1;
590
596
  const SATURATED_RING_OPACITY = 0.15;
591
597
  const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.25;
592
598
  const MINIMUM_INVERSE_CONTRAST_RATIO = 2;
593
599
  const SUBTLE_COLOR_LIGHTNESS_STEP = 0.01;
594
600
  const MAX_SUBTLE_COLOR_ADJUSTMENTS = 100;
595
601
  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;
602
+ /** OKLCH lightness shift for hovered colors already near the contrast primitive. */
603
+ const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.25;
598
604
 
599
605
  function isStateVariantKey(key: string): boolean {
600
606
  return (
607
+ key.toLowerCase().endsWith("-hovered") ||
601
608
  key.toLowerCase().endsWith("-active") ||
602
609
  key.toLowerCase().endsWith("-inactive") ||
603
610
  key.toLowerCase().endsWith("-disabled")
@@ -773,12 +780,13 @@ function addMissingRingTokens(dictionary: unknown): unknown {
773
780
  let changed = false;
774
781
 
775
782
  for (const name of THEME_COLOR_KEYS) {
776
- if (!isTokenNode(color[name])) {
783
+ const group = color[name];
784
+ if (!isPlainObject(group) || !isTokenNode(group.primary)) {
777
785
  continue;
778
786
  }
779
787
 
780
- const ringColor = `{color.${name}}`;
781
- const gap = ringLayer("{color.surface-elevated}", 3);
788
+ const ringColor = `{color.${name}.primary}`;
789
+ const gap = ringLayer("{color.elevated}", 3);
782
790
  const variants: Record<string, [string, unknown[]]> = {
783
791
  [name]: [`${name} 3px focus ring with no gap`, [ringLayer(ringColor, 3)]],
784
792
  [`${name}-subtle`]: [
@@ -804,7 +812,7 @@ function addMissingRingTokens(dictionary: unknown): unknown {
804
812
  $description: `Generated ${description}`,
805
813
  $type: "shadow",
806
814
  $value: value,
807
- theme: colorTokenTheme(name)
815
+ theme: name
808
816
  };
809
817
  changed = true;
810
818
  }
@@ -970,41 +978,41 @@ function createOnColor(hex: string): "#FAFAFA" | "#151518" {
970
978
  function createMutedColor(
971
979
  hex: string,
972
980
  parentTheme: ParentTheme
973
- ): { subtle: string; onMuted?: string } {
981
+ ): { secondary: string; onMuted?: string } {
974
982
  const { lightness, chroma, hue, alpha } = hexToOklch(hex);
975
- const subtleDirection = parentTheme === "light" ? 1 : -1;
976
- const onMutedDirection = -subtleDirection;
977
- let subtleLightness = lightness;
983
+ const secondaryDirection = parentTheme === "light" ? 1 : -1;
984
+ const onMutedDirection = -secondaryDirection;
985
+ let secondaryLightness = lightness;
978
986
  let onMutedLightness = lightness;
979
- let subtle = hex;
987
+ let secondary = hex;
980
988
  let onMuted = hex;
981
989
 
982
990
  for (let index = 0; index < MAX_SUBTLE_COLOR_ADJUSTMENTS; index += 1) {
983
- subtleLightness = clamp01(
984
- subtleLightness + subtleDirection * SUBTLE_COLOR_LIGHTNESS_STEP
991
+ secondaryLightness = clamp01(
992
+ secondaryLightness + secondaryDirection * SUBTLE_COLOR_LIGHTNESS_STEP
985
993
  );
986
- subtle = oklchToGamutMappedHex(subtleLightness, chroma, hue, alpha);
987
- if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
988
- return onMuted === hex ? { subtle } : { subtle, onMuted };
994
+ secondary = oklchToGamutMappedHex(secondaryLightness, chroma, hue, alpha);
995
+ if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
996
+ return onMuted === hex ? { secondary } : { secondary, onMuted };
989
997
  }
990
998
 
991
999
  onMutedLightness = clamp01(
992
1000
  onMutedLightness + onMutedDirection * SUBTLE_COLOR_LIGHTNESS_STEP
993
1001
  );
994
1002
  onMuted = oklchToGamutMappedHex(onMutedLightness, chroma, hue, alpha);
995
- if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
996
- return { subtle, onMuted };
1003
+ if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
1004
+ return { secondary, onMuted };
997
1005
  }
998
1006
 
999
1007
  if (
1000
- (subtleLightness === 0 || subtleLightness === 1) &&
1008
+ (secondaryLightness === 0 || secondaryLightness === 1) &&
1001
1009
  (onMutedLightness === 0 || onMutedLightness === 1)
1002
1010
  ) {
1003
1011
  break;
1004
1012
  }
1005
1013
  }
1006
1014
 
1007
- throw new Error(`Unable to generate an accessible subtle pair for ${hex}`);
1015
+ throw new Error(`Unable to generate an accessible secondary pair for ${hex}`);
1008
1016
  }
1009
1017
 
1010
1018
  function isLightColor(hex: string, parentTheme?: ParentTheme): boolean {
@@ -1031,6 +1039,20 @@ function isBlackColor(hex: string): boolean {
1031
1039
  return hexToOklch(hex).lightness <= 0.25;
1032
1040
  }
1033
1041
 
1042
+ /** Hovered and active states share the contrast primitive behavior. */
1043
+ function isInteractionState(variant: ColorStateVariant): boolean {
1044
+ return variant.name === "hovered" || variant.name === "active";
1045
+ }
1046
+
1047
+ function isNearContrastPrimitive(
1048
+ hex: string,
1049
+ parentTheme?: ParentTheme
1050
+ ): boolean {
1051
+ return parentTheme === "dark"
1052
+ ? isWhiteColor(hex)
1053
+ : parentTheme === "light" && isBlackColor(hex);
1054
+ }
1055
+
1034
1056
  /** Resolve a state multiplier from the source color and parent theme. */
1035
1057
  function directedBrightnessFactor(
1036
1058
  hex: string,
@@ -1050,7 +1072,7 @@ function contrastPrimitive(
1050
1072
  }
1051
1073
 
1052
1074
  /**
1053
- * Active colors that already sit near the parent theme's contrast primitive
1075
+ * Hovered colors that already sit near the parent theme's contrast primitive
1054
1076
  * (near-white in dark themes, near-black in light themes) would barely change
1055
1077
  * by jumping to it, so their lightness shifts toward the middle instead.
1056
1078
  */
@@ -1059,19 +1081,37 @@ function contrastSideLightnessShift(
1059
1081
  variant: ColorStateVariant,
1060
1082
  parentTheme?: ParentTheme
1061
1083
  ): number | undefined {
1062
- if (variant.name !== "active" || !contrastPrimitive(variant, parentTheme)) {
1084
+ if (
1085
+ variant.name !== "hovered" ||
1086
+ !contrastPrimitive(variant, parentTheme) ||
1087
+ !isNearContrastPrimitive(hex, parentTheme)
1088
+ ) {
1063
1089
  return undefined;
1064
1090
  }
1065
1091
 
1066
- if (parentTheme === "dark" && isWhiteColor(hex)) {
1067
- return -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1068
- }
1092
+ return parentTheme === "dark"
1093
+ ? -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT
1094
+ : CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1095
+ }
1069
1096
 
1070
- if (parentTheme === "light" && isBlackColor(hex)) {
1071
- return CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
1072
- }
1097
+ /**
1098
+ * The color an interaction state jumps to. Active colors already near the
1099
+ * contrast primitive go to pure white (dark) or pure black (light) so they
1100
+ * never lose contrast.
1101
+ */
1102
+ function stateContrastColor(
1103
+ hex: string,
1104
+ variant: ColorStateVariant,
1105
+ parentTheme?: ParentTheme
1106
+ ): string | undefined {
1107
+ const primitive = contrastPrimitive(variant, parentTheme);
1073
1108
 
1074
- return undefined;
1109
+ return primitive &&
1110
+ parentTheme &&
1111
+ variant.name === "active" &&
1112
+ isNearContrastPrimitive(hex, parentTheme)
1113
+ ? PURE_CONTRAST_COLORS[parentTheme]
1114
+ : primitive;
1075
1115
  }
1076
1116
 
1077
1117
  function stateBrightnessFactor(
@@ -1084,7 +1124,7 @@ function stateBrightnessFactor(
1084
1124
  }
1085
1125
 
1086
1126
  if (contrastPrimitive(variant, parentTheme)) {
1087
- return variant.name === "active" ? 1 : variant.brightness;
1127
+ return isInteractionState(variant) ? 1 : variant.brightness;
1088
1128
  }
1089
1129
 
1090
1130
  return directedBrightnessFactor(hex, variant.brightness, parentTheme);
@@ -1097,14 +1137,16 @@ function applyStateTransform(
1097
1137
  ): string {
1098
1138
  const shift = contrastSideLightnessShift(hex, variant, parentTheme);
1099
1139
  const primitive =
1100
- shift === undefined ? contrastPrimitive(variant, parentTheme) : undefined;
1140
+ shift === undefined
1141
+ ? stateContrastColor(hex, variant, parentTheme)
1142
+ : undefined;
1101
1143
  let transformed = primitive ?? hex;
1102
1144
 
1103
1145
  if (shift !== undefined) {
1104
1146
  transformed = applyLightnessShift(hex, shift);
1105
1147
  } else if (
1106
1148
  variant.brightness !== undefined &&
1107
- !(primitive && variant.name === "active")
1149
+ !(primitive && isInteractionState(variant))
1108
1150
  ) {
1109
1151
  transformed = applyBrightness(
1110
1152
  transformed,
@@ -1137,14 +1179,18 @@ function variantDetail(
1137
1179
  ): string {
1138
1180
  const details: string[] = [];
1139
1181
  const shift = contrastSideLightnessShift(hex, variant, parentTheme);
1140
- const primitive = contrastPrimitive(variant, parentTheme);
1182
+ const primitive = stateContrastColor(hex, variant, parentTheme);
1141
1183
 
1142
1184
  if (shift !== undefined) {
1143
1185
  details.push(
1144
1186
  `${Math.round(Math.abs(shift) * 100)}% ${shift < 0 ? "darker" : "lighter"}`
1145
1187
  );
1146
- } else if (primitive && variant.name === "active") {
1147
- details.push(`${parentTheme} contrast primitive`);
1188
+ } else if (primitive && isInteractionState(variant)) {
1189
+ details.push(
1190
+ parentTheme && primitive !== CONTRAST_PRIMITIVES[parentTheme]
1191
+ ? `pure ${parentTheme === "dark" ? "white" : "black"}`
1192
+ : `${parentTheme} contrast primitive`
1193
+ );
1148
1194
  } else if (variant.brightness !== undefined) {
1149
1195
  const factor = stateBrightnessFactor(hex, variant, parentTheme);
1150
1196
  const percent = Math.round((factor - 1) * 100);
@@ -1222,9 +1268,14 @@ function addColorStateTokens(
1222
1268
  continue;
1223
1269
  }
1224
1270
 
1225
- const tokenVariants = isLowSaturation(hex)
1226
- ? variants.lowSaturation
1227
- : variants.saturated;
1271
+ // Hovered states are generated from the matching active variant.
1272
+ const tokenVariants = (
1273
+ isLowSaturation(hex) ? variants.lowSaturation : variants.saturated
1274
+ ).flatMap(variant =>
1275
+ variant.name === "active"
1276
+ ? [{ ...variant, name: "hovered" }, variant]
1277
+ : [variant]
1278
+ );
1228
1279
  const tokenParentTheme =
1229
1280
  parentThemeFromDescription(token.$description) ?? parentTheme;
1230
1281
 
@@ -1247,144 +1298,65 @@ function addColorStateTokens(
1247
1298
  }
1248
1299
 
1249
1300
  /**
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.
1301
+ * Fill in a theme color group (`color.danger`, …): generate a missing
1302
+ * `secondary` low-emphasis background from `primary` and the parent theme,
1303
+ * `on-primary` / `on-secondary` text colors, and state variants for
1304
+ * `primary` and `secondary`. Every token in the group gets the group name as
1305
+ * its `theme` and `group`, so Tamagui places it in that child theme (e.g.
1306
+ * `danger.on-primary` → `onPrimary` in the `danger` theme).
1255
1307
  */
1256
- function addMissingStatusCompanionTokens(
1257
- color: Record<string, unknown>,
1308
+ function addThemeColorGroupTokens(
1309
+ name: string,
1310
+ group: Record<string, unknown>,
1258
1311
  tree: unknown,
1259
1312
  parentTheme?: ParentTheme
1260
1313
  ): 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
- }
1314
+ const token = group.primary;
1315
+ const hex = isTokenNode(token) && resolveColorHex(token.$value, tree);
1316
+ const type = isTokenNode(token) && resolveType(token);
1317
+ if (!isTokenNode(token) || !hex || (type && type !== "color")) {
1318
+ return group;
1319
+ }
1267
1320
 
1268
- const type = resolveType(token);
1269
- const hex = resolveColorHex(token.$value, tree);
1270
- if ((type && type !== "color") || !hex) {
1271
- continue;
1272
- }
1321
+ let result: Record<string, unknown> = { ...group };
1322
+ result["on-primary"] ??= {
1323
+ ...token,
1324
+ $type: "color",
1325
+ $value: createOnColor(hex),
1326
+ $description: `Generated text and icon color on ${name} primary backgrounds`
1327
+ };
1273
1328
 
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`] ??= {
1329
+ let onSecondary: string | undefined;
1330
+ if (parentTheme && !("secondary" in group)) {
1331
+ const pair = createMutedColor(hex, parentTheme);
1332
+ result.secondary = {
1333
+ ...token,
1292
1334
  $type: "color",
1293
- $value: `{color.${name}}`,
1294
- $description: `Alias of ${name} for the ${name} theme`,
1295
- theme: name,
1296
- group: name
1335
+ $value: pair.secondary,
1336
+ $description: `Generated low-emphasis ${name} background for the ${parentTheme} theme`
1297
1337
  };
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
- }
1338
+ onSecondary = pair.onMuted ?? hex;
1339
+ } else if (isTokenNode(group.secondary)) {
1340
+ const secondaryHex = resolveColorHex(group.secondary.$value, tree);
1341
+ onSecondary = secondaryHex && createOnColor(secondaryHex);
1325
1342
  }
1326
1343
 
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)] ??= {
1344
+ if (onSecondary && !("on-secondary" in group)) {
1345
+ result["on-secondary"] = {
1370
1346
  ...token,
1371
- theme: "base",
1372
- group: "base"
1347
+ $type: "color",
1348
+ $value: onSecondary,
1349
+ $description: `Generated text and icon color on ${name} secondary backgrounds`
1373
1350
  };
1374
1351
  }
1375
1352
 
1376
- return result;
1377
- }
1353
+ for (const [key, variants] of Object.entries(THEME_COLOR_STATE_VARIANTS)) {
1354
+ result = addColorStateTokens(result, tree, variants, key, parentTheme);
1355
+ }
1378
1356
 
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 };
1357
+ for (const [key, value] of Object.entries(result)) {
1358
+ if (isTokenNode(value)) {
1359
+ result[key] = { theme: name, group: name, ...value };
1388
1360
  }
1389
1361
  }
1390
1362
 
@@ -1392,15 +1364,17 @@ function addColorTokenThemes(
1392
1364
  }
1393
1365
 
1394
1366
  /**
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`.
1367
+ * Add a `group` (the main token name) to each state and `on-*` color token
1368
+ * that does not define one, e.g. `hairline-active` → `hairline`.
1397
1369
  */
1398
1370
  function addColorTokenGroups(
1399
1371
  group: Record<string, unknown>
1400
1372
  ): Record<string, unknown> {
1401
1373
  const result = { ...group };
1402
1374
  for (const [name, token] of Object.entries(group)) {
1403
- const root = colorTokenRoot(name);
1375
+ const root = name
1376
+ .replace(/^on-/, "")
1377
+ .replace(/-(?:hovered|active|inactive|disabled)$/i, "");
1404
1378
  if (root !== name && isTokenNode(token) && !("group" in token)) {
1405
1379
  result[name] = { ...token, group: root };
1406
1380
  }
@@ -1442,26 +1416,37 @@ function injectColorStateVariants(
1442
1416
  );
1443
1417
  }
1444
1418
 
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
- }
1419
+ let withStates = result;
1420
+ if (key === "color") {
1421
+ for (const name of THEME_COLOR_KEYS) {
1422
+ const group = withStates[name];
1423
+ if (isPlainObject(group) && !isTokenNode(group)) {
1424
+ withStates[name] = addThemeColorGroupTokens(
1425
+ name,
1426
+ group,
1427
+ tree,
1428
+ parentTheme
1429
+ );
1430
+ }
1431
+ }
1456
1432
 
1457
- let withStates =
1458
- key === "color"
1459
- ? addMissingStatusCompanionTokens(
1460
- addBaseThemeAliases(result),
1433
+ for (const [tokenKey, token] of Object.entries(withStates)) {
1434
+ const variants =
1435
+ isTokenNode(token) && typeof token.group === "string"
1436
+ ? COLOR_STATE_TOKEN_GROUPS[token.group]
1437
+ : undefined;
1438
+ if (variants) {
1439
+ withStates = addColorStateTokens(
1440
+ withStates,
1461
1441
  tree,
1442
+ variants,
1443
+ tokenKey,
1462
1444
  parentTheme
1463
- )
1464
- : result;
1445
+ );
1446
+ }
1447
+ }
1448
+ }
1449
+
1465
1450
  for (const [tokenKey, variants] of Object.entries(COLOR_STATE_VARIANTS)) {
1466
1451
  if (isTokenNode(withStates[tokenKey])) {
1467
1452
  withStates = addColorStateTokens(
@@ -1474,31 +1459,29 @@ function injectColorStateVariants(
1474
1459
  }
1475
1460
  }
1476
1461
 
1477
- return key === "color"
1478
- ? addColorTokenGroups(addColorTokenThemes(withStates))
1479
- : withStates;
1462
+ return key === "color" ? addColorTokenGroups(withStates) : withStates;
1480
1463
  }
1481
1464
 
1482
1465
  /**
1483
1466
  * Style Dictionary preprocessor: rewrite color `$value`s (and nested shadow
1484
1467
  * 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.
1468
+ * `oklch()`. Root colors (emphasis, ink, subtle, subtlest, hairline, backgrounds)
1469
+ * stay in the root light and dark themes without rings. For the `base` group and each
1470
+ * status group, a missing `secondary` background and `on-primary` /
1471
+ * `on-secondary` text colors are generated, then hovered, active, inactive,
1472
+ * and disabled variants are added to the hairline, `foreground` and
1473
+ * `background` group colors, and each theme color group's `primary` and `secondary`.
1493
1474
  * 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`).
1475
+ * primitive (backgrounds use a half-strength shift of their own lightness; hovered
1476
+ * colors already near that primitive shift toward the middle, while active
1477
+ * ones become pure white or black); saturated
1478
+ * colors scale their own lightness. Tokens in a theme color group get the
1479
+ * group name as `theme` and `group`, unless already set; root state and `on-*`
1480
+ * tokens get their main token name as `group` (e.g. `hairline-active` →
1481
+ * `hairline`).
1499
1482
  * Finally, focus rings are generated for `base` and each status color.
1500
1483
  */
1501
- function tamaguiPreprocessor(
1484
+ export function tamaguiPreprocessor(
1502
1485
  dictionary: PreprocessedTokens
1503
1486
  ): PreprocessedTokens {
1504
1487
  const converted = walk(dictionary) as PreprocessedTokens;