@multiplatform.one/theme 8.2.2 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/cjs/index.gnome.cjs +4 -1
  2. package/dist/cjs/index.gnome.native.js +3 -0
  3. package/dist/cjs/index.gnome.native.js.map +1 -1
  4. package/dist/cjs/menuRow.cjs +1 -1
  5. package/dist/cjs/menuRow.native.js +1 -1
  6. package/dist/cjs/theme/accentInk.cjs +6 -2
  7. package/dist/cjs/theme/accentInk.native.js +6 -2
  8. package/dist/cjs/theme/accentInk.native.js.map +1 -1
  9. package/dist/cjs/theme/chartPalette.cjs +3 -3
  10. package/dist/cjs/theme/chartPalette.native.js +5 -4
  11. package/dist/cjs/theme/chartPalette.native.js.map +1 -1
  12. package/dist/cjs/theme/colorRules.cjs +5 -0
  13. package/dist/cjs/theme/colorRules.native.js +6 -0
  14. package/dist/cjs/theme/colorRules.native.js.map +1 -1
  15. package/dist/cjs/theme/controlTiers.cjs +9 -5
  16. package/dist/cjs/theme/controlTiers.native.js +10 -5
  17. package/dist/cjs/theme/controlTiers.native.js.map +1 -1
  18. package/dist/cjs/theme/createThemes.cjs +212 -8
  19. package/dist/cjs/theme/createThemes.native.js +397 -12
  20. package/dist/cjs/theme/createThemes.native.js.map +1 -1
  21. package/dist/cjs/theme/defaults/builderOptions.cjs +9 -4
  22. package/dist/cjs/theme/defaults/builderOptions.native.js +9 -4
  23. package/dist/cjs/theme/defaults/builderOptions.native.js.map +1 -1
  24. package/dist/cjs/theme/fillContrast.cjs +4 -1
  25. package/dist/cjs/theme/fillContrast.native.js +4 -1
  26. package/dist/cjs/theme/fillContrast.native.js.map +1 -1
  27. package/dist/cjs/theme/index.cjs +1 -0
  28. package/dist/cjs/theme/index.native.js +1 -0
  29. package/dist/cjs/theme/index.native.js.map +1 -1
  30. package/dist/cjs/theme/nativeSchemePreference.cjs +2 -1
  31. package/dist/cjs/theme/nativeSchemePreference.native.js +2 -1
  32. package/dist/cjs/theme/nativeSchemePreference.native.js.map +1 -1
  33. package/dist/cjs/theme/nativeTheme.native.js.map +1 -1
  34. package/dist/cjs/theme/overlaySurfaces.cjs +2 -2
  35. package/dist/cjs/theme/overlaySurfaces.native.js +2 -2
  36. package/dist/cjs/theme/overlaySurfaces.native.js.map +1 -1
  37. package/dist/cjs/theme/readableColor.cjs +20 -9
  38. package/dist/cjs/theme/readableColor.native.js +20 -9
  39. package/dist/cjs/theme/readableColor.native.js.map +1 -1
  40. package/dist/cjs/theme/reducedMotion.cjs +49 -0
  41. package/dist/cjs/theme/reducedMotion.native.js +81 -0
  42. package/dist/cjs/theme/reducedMotion.native.js.map +1 -0
  43. package/dist/cjs/theme/resolveKnobs.cjs +4 -1
  44. package/dist/cjs/theme/resolveKnobs.native.js +4 -1
  45. package/dist/cjs/theme/resolveKnobs.native.js.map +1 -1
  46. package/dist/cjs/theme/tokenNumber.cjs +34 -0
  47. package/dist/cjs/theme/tokenNumber.native.js +39 -0
  48. package/dist/cjs/theme/tokenNumber.native.js.map +1 -0
  49. package/dist/cjs/theme/useResolvedKnobs.cjs +2 -22
  50. package/dist/cjs/theme/useResolvedKnobs.native.js +2 -30
  51. package/dist/cjs/theme/useResolvedKnobs.native.js.map +1 -1
  52. package/dist/esm/index.gnome.mjs +3 -2
  53. package/dist/esm/index.gnome.mjs.map +1 -1
  54. package/dist/esm/index.gnome.native.js +3 -2
  55. package/dist/esm/index.gnome.native.js.map +1 -1
  56. package/dist/esm/menuRow.mjs +1 -1
  57. package/dist/esm/menuRow.native.js +1 -1
  58. package/dist/esm/theme/accentInk.mjs +6 -3
  59. package/dist/esm/theme/accentInk.mjs.map +1 -1
  60. package/dist/esm/theme/accentInk.native.js +6 -3
  61. package/dist/esm/theme/accentInk.native.js.map +1 -1
  62. package/dist/esm/theme/chartPalette.mjs +3 -3
  63. package/dist/esm/theme/chartPalette.mjs.map +1 -1
  64. package/dist/esm/theme/chartPalette.native.js +5 -4
  65. package/dist/esm/theme/chartPalette.native.js.map +1 -1
  66. package/dist/esm/theme/colorRules.mjs +5 -1
  67. package/dist/esm/theme/colorRules.mjs.map +1 -1
  68. package/dist/esm/theme/colorRules.native.js +6 -1
  69. package/dist/esm/theme/colorRules.native.js.map +1 -1
  70. package/dist/esm/theme/controlTiers.mjs +9 -6
  71. package/dist/esm/theme/controlTiers.mjs.map +1 -1
  72. package/dist/esm/theme/controlTiers.native.js +10 -6
  73. package/dist/esm/theme/controlTiers.native.js.map +1 -1
  74. package/dist/esm/theme/createThemes.mjs +215 -12
  75. package/dist/esm/theme/createThemes.mjs.map +1 -1
  76. package/dist/esm/theme/createThemes.native.js +400 -16
  77. package/dist/esm/theme/createThemes.native.js.map +1 -1
  78. package/dist/esm/theme/defaults/builderOptions.mjs +10 -6
  79. package/dist/esm/theme/defaults/builderOptions.mjs.map +1 -1
  80. package/dist/esm/theme/defaults/builderOptions.native.js +10 -6
  81. package/dist/esm/theme/defaults/builderOptions.native.js.map +1 -1
  82. package/dist/esm/theme/fillContrast.mjs +5 -2
  83. package/dist/esm/theme/fillContrast.mjs.map +1 -1
  84. package/dist/esm/theme/fillContrast.native.js +5 -2
  85. package/dist/esm/theme/fillContrast.native.js.map +1 -1
  86. package/dist/esm/theme/index.mjs +1 -0
  87. package/dist/esm/theme/index.mjs.map +1 -1
  88. package/dist/esm/theme/index.native.js +1 -0
  89. package/dist/esm/theme/index.native.js.map +1 -1
  90. package/dist/esm/theme/nativeSchemePreference.mjs +2 -1
  91. package/dist/esm/theme/nativeSchemePreference.mjs.map +1 -1
  92. package/dist/esm/theme/nativeSchemePreference.native.js +2 -1
  93. package/dist/esm/theme/nativeSchemePreference.native.js.map +1 -1
  94. package/dist/esm/theme/nativeTheme.mjs.map +1 -1
  95. package/dist/esm/theme/nativeTheme.native.js.map +1 -1
  96. package/dist/esm/theme/overlaySurfaces.mjs +2 -2
  97. package/dist/esm/theme/overlaySurfaces.native.js +2 -2
  98. package/dist/esm/theme/readableColor.mjs +20 -9
  99. package/dist/esm/theme/readableColor.mjs.map +1 -1
  100. package/dist/esm/theme/readableColor.native.js +20 -9
  101. package/dist/esm/theme/readableColor.native.js.map +1 -1
  102. package/dist/esm/theme/reducedMotion.mjs +24 -0
  103. package/dist/esm/theme/reducedMotion.mjs.map +1 -0
  104. package/dist/esm/theme/reducedMotion.native.js +53 -0
  105. package/dist/esm/theme/reducedMotion.native.js.map +1 -0
  106. package/dist/esm/theme/resolveKnobs.mjs +4 -1
  107. package/dist/esm/theme/resolveKnobs.mjs.map +1 -1
  108. package/dist/esm/theme/resolveKnobs.native.js +4 -1
  109. package/dist/esm/theme/resolveKnobs.native.js.map +1 -1
  110. package/dist/esm/theme/tokenNumber.mjs +9 -0
  111. package/dist/esm/theme/tokenNumber.mjs.map +1 -0
  112. package/dist/esm/theme/tokenNumber.native.js +11 -0
  113. package/dist/esm/theme/tokenNumber.native.js.map +1 -0
  114. package/dist/esm/theme/useResolvedKnobs.mjs +2 -22
  115. package/dist/esm/theme/useResolvedKnobs.mjs.map +1 -1
  116. package/dist/esm/theme/useResolvedKnobs.native.js +2 -30
  117. package/dist/esm/theme/useResolvedKnobs.native.js.map +1 -1
  118. package/package.json +5 -5
  119. package/src/figma/figmaTokens.spec.ts +2 -1
  120. package/src/index.gnome.ts +2 -0
  121. package/src/menuRow.palette.spec.ts +20 -7
  122. package/src/menuRow.spec.ts +2 -2
  123. package/src/menuRow.ts +3 -3
  124. package/src/theme/accentInk.ts +8 -2
  125. package/src/theme/accentOnSurface.spec.tsx +6 -1
  126. package/src/theme/chartPalette.spec.ts +2 -2
  127. package/src/theme/chartPalette.ts +9 -9
  128. package/src/theme/colorRules.ts +13 -0
  129. package/src/theme/controlEdges.spec.ts +114 -0
  130. package/src/theme/controlTiers.ts +18 -9
  131. package/src/theme/createDefaultThemeConfig.spec.ts +135 -12
  132. package/src/theme/createThemes.ts +363 -24
  133. package/src/theme/defaults/builderOptions.ts +25 -8
  134. package/src/theme/defaults/secondaryInk.spec.ts +10 -5
  135. package/src/theme/fillContrast.spec.ts +8 -2
  136. package/src/theme/fillContrast.ts +10 -2
  137. package/src/theme/houseGround.spec.ts +1 -1
  138. package/src/theme/index.ts +1 -0
  139. package/src/theme/nativeSchemePreference.ts +2 -1
  140. package/src/theme/nativeTheme.tsx +1 -1
  141. package/src/theme/overlaySurfaces.tsx +2 -2
  142. package/src/theme/readableColor.ts +23 -11
  143. package/src/theme/recipes.ts +1 -0
  144. package/src/theme/reducedMotion.native.spec.ts +28 -0
  145. package/src/theme/reducedMotion.native.ts +36 -0
  146. package/src/theme/reducedMotion.ts +27 -0
  147. package/src/theme/resolveKnobs.spec.ts +4 -4
  148. package/src/theme/resolveKnobs.ts +6 -4
  149. package/src/theme/selectionFill.spec.ts +58 -0
  150. package/src/theme/sheetGrabber.spec.ts +4 -0
  151. package/src/theme/switchThumb.spec.ts +13 -1
  152. package/src/theme/themeKeyRegistry.spec.ts +26 -0
  153. package/src/theme/tokenNumber.spec.tsx +47 -0
  154. package/src/theme/tokenNumber.ts +18 -0
  155. package/src/theme/useResolvedKnobs.ts +2 -27
  156. package/types/index.gnome.d.ts +2 -1
  157. package/types/index.gnome.d.ts.map +1 -1
  158. package/types/menuRow.d.ts +5 -5
  159. package/types/theme/accentInk.d.ts +1 -0
  160. package/types/theme/accentInk.d.ts.map +1 -1
  161. package/types/theme/chartPalette.d.ts +2 -2
  162. package/types/theme/colorRules.d.ts +9 -0
  163. package/types/theme/colorRules.d.ts.map +1 -1
  164. package/types/theme/controlTiers.d.ts +11 -5
  165. package/types/theme/controlTiers.d.ts.map +1 -1
  166. package/types/theme/createThemes.d.ts +21 -12
  167. package/types/theme/createThemes.d.ts.map +1 -1
  168. package/types/theme/defaults/builderOptions.d.ts +9 -1
  169. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  170. package/types/theme/fillContrast.d.ts.map +1 -1
  171. package/types/theme/index.d.ts +1 -0
  172. package/types/theme/index.d.ts.map +1 -1
  173. package/types/theme/nativeSchemePreference.d.ts.map +1 -1
  174. package/types/theme/nativeTheme.d.ts +1 -1
  175. package/types/theme/nativeTheme.d.ts.map +1 -1
  176. package/types/theme/overlaySurfaces.d.ts +2 -2
  177. package/types/theme/readableColor.d.ts.map +1 -1
  178. package/types/theme/recipes.d.ts +3 -0
  179. package/types/theme/recipes.d.ts.map +1 -1
  180. package/types/theme/reducedMotion.d.ts +3 -0
  181. package/types/theme/reducedMotion.d.ts.map +1 -0
  182. package/types/theme/reducedMotion.native.d.ts +3 -0
  183. package/types/theme/reducedMotion.native.d.ts.map +1 -0
  184. package/types/theme/resolveKnobs.d.ts.map +1 -1
  185. package/types/theme/tokenNumber.d.ts +10 -0
  186. package/types/theme/tokenNumber.d.ts.map +1 -0
  187. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
@@ -18,12 +18,14 @@ import {
18
18
  plateContrastRatio,
19
19
  relativeLuminance,
20
20
  switchOffFillAlpha,
21
+ switchOffGround,
21
22
  systemFillHex,
22
23
  systemFillLuminance,
23
24
  } from "./colorRules";
24
- import { pickSecondaryInk } from "./defaults/builderOptions";
25
+ import { darkHairlineContrast, pickSecondaryInk } from "./defaults/builderOptions";
25
26
  import { themeRampHues } from "./ramps";
26
- import { withAccentOnSurface, withIdentityInk } from "./accentInk";
27
+ import { pageSurfaces, pickIdentityInk, withAccentOnSurface, withIdentityInk } from "./accentInk";
28
+ import { resolveThemeThroughParents } from "./dedupeInherited";
27
29
  import { tintHueNames } from "./tintHues";
28
30
 
29
31
  export { tintHueNames };
@@ -79,9 +81,63 @@ const lightSurfaceGrouped = {
79
81
  backgroundActive: PALETTE_BACKGROUND_OFFSET,
80
82
  };
81
83
 
84
+ /**
85
+ * LC-151 in dark (FZ-8400): the page drops to the ramp's darkest step
86
+ * (`$color1`) and the grouped surfaces climb one step (surface1 `$color4`,
87
+ * surface2 `$color5`), so a card, list group or field steps off the page by at
88
+ * least iOS's grouped step (`#1C1C1E` on black, 1.23:1). v5 stock put
89
+ * surface1 one step over a `$color2` page, `#191919` on `#151515` (1.07:1),
90
+ * and groups vanished. A Button's surface2 climbs with them so it still sits a
91
+ * step off the card. Light is untouched, and the ramp stays stock v5.
92
+ */
93
+ const fillKeys = [
94
+ "background",
95
+ "backgroundHover",
96
+ "backgroundPress",
97
+ "backgroundFocus",
98
+ "backgroundActive",
99
+ ] as const;
100
+ const edgeKeys = [
101
+ "borderColor",
102
+ "borderColorHover",
103
+ "borderColorFocus",
104
+ "borderColorPress",
105
+ ] as const;
106
+
107
+ function liftOneStep<T extends Record<string, number>>(template: T) {
108
+ const lifted: Record<string, number> = { ...template };
109
+ for (const key of [...fillKeys, ...edgeKeys]) lifted[key] = template[key] + 1;
110
+ return lifted as T;
111
+ }
112
+
113
+ /**
114
+ * Cards, list groups and fields in dark: surface1 lifted, and the LC-05
115
+ * selection fills re-pointed one step above the card's own `$color4`, so a
116
+ * selected or pressed row inside an inset group never lands on the group's
117
+ * fill. On the page they stay `$color4` and `$color5`. Each theme key costs
118
+ * about 1.4 KB of theme CSS, so there are two.
119
+ */
120
+ const darkSurfaceGrouped = {
121
+ ...liftOneStep(v5Templates.dark_surface1),
122
+ selectFill: PALETTE_BACKGROUND_OFFSET + 4,
123
+ selectPress: PALETTE_BACKGROUND_OFFSET + 5,
124
+ };
125
+
126
+ const darkBasePage = {
127
+ ...v5Templates.dark_base,
128
+ background: PALETTE_BACKGROUND_OFFSET,
129
+ backgroundActive: PALETTE_BACKGROUND_OFFSET,
130
+ backgroundFocus: PALETTE_BACKGROUND_OFFSET + 1,
131
+ backgroundHover: PALETTE_BACKGROUND_OFFSET + 1,
132
+ backgroundPress: PALETTE_BACKGROUND_OFFSET + 1,
133
+ };
134
+
82
135
  export const houseTemplates = {
83
136
  ...v5Templates,
84
137
  light_surface1: lightSurfaceGrouped,
138
+ dark_base: darkBasePage,
139
+ dark_surface1: darkSurfaceGrouped,
140
+ dark_surface2: liftOneStep(v5Templates.dark_surface2),
85
141
  };
86
142
 
87
143
  export const houseComponentThemes = {
@@ -296,7 +352,15 @@ function holdAccentFloor(
296
352
  if (!base?.accentBackground || !base.accentColor) continue;
297
353
  const paper = luminanceOf(base.color1);
298
354
  const ink = luminanceOf(base.color12);
299
- const surfaces = [base.color1, base.color2, base.color3, base.color4].map(luminanceOf);
355
+ // A dark overlay paints `$color5` (LC-85, FZ-8400), and a Toast's accent
356
+ // action sits on it.
357
+ const surfaces = [
358
+ base.color1,
359
+ base.color2,
360
+ base.color3,
361
+ base.color4,
362
+ ...(scheme === "dark" ? [base.color5] : []),
363
+ ].map(luminanceOf);
300
364
  if (paper === null || ink === null || surfaces.some((lum) => lum === null)) continue;
301
365
  const steps = Array.from({ length: 12 }, (_, index) => base[`accent${index + 1}`]);
302
366
  const fill = nearestHoldingStep(
@@ -367,12 +431,188 @@ function holdSelectionSurface(
367
431
  ...out[name],
368
432
  color4: held,
369
433
  ...(theme.borderColor === scope.color4 ? { borderColor: held } : null),
434
+ ...(theme.selectFill === scope.color4 ? { selectFill: held } : null),
370
435
  };
371
436
  }
372
437
  }
373
438
  return out;
374
439
  }
375
440
 
441
+ /**
442
+ * LC-148 in dark (FZ-9281): a dark scope's hairline is the first ramp step
443
+ * that clears `darkHairlineContrast` against its page and its grouped surface
444
+ * (`$color4`), and its medium edge is the next step up. One step toward the
445
+ * page put the hairline under the card's own fill, and the medium edge on the
446
+ * card's fill, so both vanished. The picks are measured per scope, so a ramp
447
+ * change moves them with it. Edge states keep their offsets from the medium
448
+ * edge. Light keeps v5's edges.
449
+ */
450
+ function holdDarkEdges(
451
+ themes: Record<string, Record<string, string>>,
452
+ ): Record<string, Record<string, string>> {
453
+ const out: Record<string, Record<string, string>> = { ...themes };
454
+ const scopes = Object.keys(themes).filter((name) => /^dark(_[a-z]+)?$/.test(name));
455
+ const ownerOf = (name: string) =>
456
+ scopes
457
+ .filter((scope) => name === scope || name.startsWith(`${scope}_`))
458
+ .reduce((longest, scope) => (scope.length > longest.length ? scope : longest), "");
459
+ for (const scopeName of scopes) {
460
+ if (/_(accent|active)$/.test(scopeName)) continue;
461
+ const scope = themes[scopeName]!;
462
+ const ramp = Array.from({ length: 12 }, (_, index) => scope[`color${index + 1}`]);
463
+ const grounds = [scope.background, scope.color4].map((color) => luminanceOf(color));
464
+ if (grounds.some((ground) => ground === null)) continue;
465
+ const brightest = Math.max(...(grounds as number[]));
466
+ const clears = (color: string | undefined) => {
467
+ const luminance = luminanceOf(color);
468
+ return (
469
+ luminance !== null &&
470
+ luminance > brightest &&
471
+ (grounds as number[]).every(
472
+ (ground) => contrastRatio(luminance, ground) >= darkHairlineContrast,
473
+ )
474
+ );
475
+ };
476
+ const hairline = ramp.findIndex((color, index) => index < 11 && clears(color));
477
+ if (hairline < 0) continue;
478
+ const medium = hairline + 1;
479
+ for (const [name, theme] of Object.entries(themes)) {
480
+ if (ownerOf(name) !== scopeName) continue;
481
+ const surface = name === scopeName || theme.background === scope.color4;
482
+ const step = ramp.indexOf(theme.borderColor);
483
+ const shift = surface && step >= 0 ? medium - step : 0;
484
+ const moved: Record<string, string> = {};
485
+ if ("borderColorHairline" in theme || name === scopeName)
486
+ moved.borderColorHairline = ramp[hairline]!;
487
+ if (shift > 0) {
488
+ for (const key of [
489
+ "borderColor",
490
+ "borderColorHover",
491
+ "borderColorFocus",
492
+ "borderColorPress",
493
+ ]) {
494
+ const at = ramp.indexOf(theme[key]);
495
+ if (at >= 0) moved[key] = ramp[Math.min(11, at + shift)]!;
496
+ }
497
+ }
498
+ out[name] = { ...out[name], ...moved };
499
+ }
500
+ }
501
+ return out;
502
+ }
503
+
504
+ /**
505
+ * LC-299 CONTROL-EDGE (FZ-9358, WCAG 1.4.11): a field's edge
506
+ * (`$borderColorField`) is the first neutral step at 3:1 on the page and on
507
+ * the field's own fill, and its hover never drops under it.
508
+ * `$borderColorControl` is the same pick on the page alone, for the segmented
509
+ * frame and the light off Switch track. Dark's off track stays edgeless: its
510
+ * white thumb already clears 3:1. A semantic intent's field edge is the first
511
+ * step of its own ramp at 3:1 on every page and on its fill, so error stays red.
512
+ * Picks sit on the scheme roots and a scope overrides only where its own
513
+ * differs: one copy per component sub-theme overruns the CSS budget.
514
+ */
515
+ function holdControlEdges(
516
+ themes: Record<string, Record<string, string>>,
517
+ ): Record<string, Record<string, string>> {
518
+ const out: Record<string, Record<string, string>> = { ...themes };
519
+ const firstClearing = (neutral: string[], grounds: (string | undefined)[]) => {
520
+ const lums = grounds.map((ground) => luminanceOf(ground));
521
+ if (lums.some((lum) => lum === null)) return -1;
522
+ const darkest = Math.min(...(lums as number[]));
523
+ const brightest = Math.max(...(lums as number[]));
524
+ const light = isLightGround(grounds[0]!);
525
+ return neutral.findIndex((color) => {
526
+ const lum = luminanceOf(color);
527
+ return (
528
+ lum !== null &&
529
+ (light ? lum < darkest : lum > brightest) &&
530
+ (lums as number[]).every((ground) => contrastRatio(lum, ground) >= minContrastRatio)
531
+ );
532
+ });
533
+ };
534
+ const resolved = (name: string, key: string): string | undefined => {
535
+ for (let at = name; at; at = at.slice(0, Math.max(0, at.lastIndexOf("_")))) {
536
+ const value = (out[at] ?? themes[at])?.[key];
537
+ if (value !== undefined) return value;
538
+ if (!at.includes("_")) break;
539
+ }
540
+ return undefined;
541
+ };
542
+ for (const scheme of ["light", "dark"] as const) {
543
+ const root = themes[scheme];
544
+ if (!root) continue;
545
+ const neutral = Array.from({ length: 12 }, (_, index) => root[`color${index + 1}`]!);
546
+ const fieldEdges = (scopeName: string) => {
547
+ const scope = themes[scopeName]!;
548
+ const field = `${scopeName}_Input`;
549
+ const edge = firstClearing(neutral, [scope.background, resolved(field, "background")]);
550
+ if (edge < 0) return undefined;
551
+ const ramp = Array.from({ length: 12 }, (_, index) => scope[`color${index + 1}`]);
552
+ const stepOf = (color: string | undefined) => {
553
+ const lum = luminanceOf(color);
554
+ if (lum === null) return -1;
555
+ return ramp.reduce(
556
+ (best, step, index) =>
557
+ Math.abs((luminanceOf(step) ?? 2) - lum) <
558
+ Math.abs((luminanceOf(ramp[best]) ?? 2) - lum)
559
+ ? index
560
+ : best,
561
+ 0,
562
+ );
563
+ };
564
+ const rest = stepOf(resolved(field, "borderColor"));
565
+ const hover = stepOf(resolved(field, "borderColorHover"));
566
+ return {
567
+ borderColorField: neutral[edge]!,
568
+ borderColorFieldHover: neutral[Math.min(11, Math.max(edge, edge + hover - rest))]!,
569
+ };
570
+ };
571
+ const rootFields = fieldEdges(scheme);
572
+ const rootControl = firstClearing(neutral, [root.background]);
573
+ if (!rootFields || rootControl < 0) continue;
574
+ out[scheme] = {
575
+ ...out[scheme],
576
+ ...rootFields,
577
+ borderColorControl: neutral[rootControl]!,
578
+ switchTrackOffBorder: scheme === "light" ? neutral[rootControl]! : root.switchTrackOff!,
579
+ };
580
+ const scopes = Object.keys(themes).filter(
581
+ (name) => new RegExp(`^${scheme}_[a-z]+$`).test(name) && !/_(accent|active)$/.test(name),
582
+ );
583
+ const pages = [
584
+ root.background,
585
+ ...scopes
586
+ .filter((name) => !semanticIntentNames.has(name.slice(scheme.length + 1)))
587
+ .map((name) => themes[name]!.background),
588
+ ];
589
+ for (const scopeName of scopes) {
590
+ const scope = themes[scopeName]!;
591
+ const patch: Record<string, string> = {};
592
+ if (scheme === "dark" && "switchTrackOff" in scope)
593
+ patch.switchTrackOffBorder = scope.switchTrackOff!;
594
+ if (semanticIntentNames.has(scopeName.slice(scheme.length + 1))) {
595
+ const field = `${scopeName}_Input`;
596
+ const ramp = Array.from({ length: 12 }, (_, index) => scope[`color${index + 1}`]!);
597
+ const edge = firstClearing(ramp, [...pages, resolved(field, "background")]);
598
+ patch.borderColorField = edge < 0 ? resolved(field, "borderColor")! : ramp[edge]!;
599
+ patch.borderColorFieldHover =
600
+ edge < 0 ? resolved(field, "borderColorHover")! : ramp[Math.min(11, edge + 1)]!;
601
+ } else {
602
+ const fields = fieldEdges(scopeName);
603
+ if (fields) {
604
+ for (const [key, value] of Object.entries(fields))
605
+ if (value !== rootFields[key as keyof typeof rootFields]) patch[key] = value;
606
+ }
607
+ const control = firstClearing(neutral, [scope.background]);
608
+ if (control >= 0 && control !== rootControl) patch.borderColorControl = neutral[control]!;
609
+ }
610
+ if (Object.keys(patch).length > 0) out[scopeName] = { ...out[scopeName], ...patch };
611
+ }
612
+ }
613
+ return out;
614
+ }
615
+
376
616
  /**
377
617
  * LC-131 SELECTED-STATE. A selected choice part paints a soft accent tint with
378
618
  * accent ink. The tint is the accent step nearest the scheme's neutral
@@ -475,7 +715,10 @@ const switchStateContrastRatio = 1.5;
475
715
  const switchRaisedAlphas = [0.16, 0.2, 0.24, 0.28, 0.32, 0.36, 0.4];
476
716
  const switchRaisedAlphaStep = 0.04;
477
717
 
478
- /** Dark's raised ground under a Switch: `$color4`, the elevated surface knob (FZ-7944). */
718
+ /**
719
+ * Dark's raised grounds under a Switch: the `$color4` card or field it sits in
720
+ * and the `$color5` overlay (LC-151 and LC-85, FZ-8400; FZ-7944's plate floor).
721
+ */
479
722
  function raisedGrounds(
480
723
  themes: Record<string, Record<string, string>>,
481
724
  scheme: "light" | "dark",
@@ -484,9 +727,9 @@ function raisedGrounds(
484
727
  const hues = Object.entries(themes)
485
728
  .filter(([name]) => new RegExp(`^${scheme}_[a-z]+$`).test(name))
486
729
  .filter(([name]) => !/_(accent|active)$/.test(name));
487
- return [themes[scheme]?.color4, ...hues.map(([, theme]) => theme.color4)].filter(
488
- (color): color is string => typeof color === "string",
489
- );
730
+ return [themes[scheme], ...hues.map(([, theme]) => theme)]
731
+ .flatMap((theme) => [theme?.color4, theme?.color5])
732
+ .filter((color): color is string => typeof color === "string");
490
733
  }
491
734
 
492
735
  function holdSwitchTrack(
@@ -496,13 +739,36 @@ function holdSwitchTrack(
496
739
  for (const scheme of ["light", "dark"] as const) {
497
740
  const base = themes[scheme];
498
741
  if (!base) continue;
742
+ // A dark switch sits on the `$color4` card or field (LC-151, FZ-8400), as
743
+ // UISwitch does on a grouped cell, so its band is measured there.
499
744
  const paint = switchTrackPaint(
500
745
  base,
501
746
  base.accentBackground,
502
- base.background,
747
+ switchOffGround(base),
503
748
  raisedGrounds(themes, scheme),
504
749
  );
505
750
  if (paint) out[scheme] = { ...base, ...paint };
751
+ if (scheme !== "dark" || !paint) continue;
752
+ // A semantic intent's card is its own hue's `$color4`: repaint where the
753
+ // root's off track leaves the band or the plate floor there.
754
+ for (const intent of semanticIntentNames) {
755
+ const scope = themes[`${scheme}_${intent}`];
756
+ const off = luminanceOf(paint.switchTrackOff);
757
+ if (!scope?.color4 || !scope.color5 || off === null) continue;
758
+ const holds =
759
+ inSwitchOffBand(paint.switchTrackOff, scope.color4) &&
760
+ [scope.color4, scope.color5].every(
761
+ (ground) => contrastRatio(off, luminanceOf(ground) ?? off) >= plateContrastRatio,
762
+ );
763
+ if (holds) continue;
764
+ const track = switchTrackPaint(
765
+ base,
766
+ scope.accentBackground ?? base.accentBackground,
767
+ scope.color4,
768
+ [base.color4, scope.color4, scope.color5],
769
+ );
770
+ if (track) out[`${scheme}_${intent}`] = { ...scope, ...track };
771
+ }
506
772
  }
507
773
  return out;
508
774
  }
@@ -766,7 +1032,13 @@ function holdColorThemeAccent(
766
1032
  if (!scope || ramp.some((step) => !step)) continue;
767
1033
  const paper = luminanceOf(scope.color1);
768
1034
  const ink = luminanceOf(scope.color12);
769
- const surfaces = [scope.color1, scope.color2, scope.color3, scope.color4].map(luminanceOf);
1035
+ const surfaces = [
1036
+ scope.color1,
1037
+ scope.color2,
1038
+ scope.color3,
1039
+ scope.color4,
1040
+ ...(scheme === "dark" ? [scope.color5] : []),
1041
+ ].map(luminanceOf);
770
1042
  if (paper === null || ink === null || surfaces.some((lum) => lum === null)) continue;
771
1043
  const labels = [scope.color1, scope.color12].sort(
772
1044
  (a, b) => (luminanceOf(b) as number) - (luminanceOf(a) as number),
@@ -798,17 +1070,22 @@ function holdColorThemeAccent(
798
1070
  // A hue fill can sit on the root's off grey: repaint inside the band.
799
1071
  const rootOn = luminanceOf(base.switchTrackOff);
800
1072
  const fillLum = luminanceOf(fill);
1073
+ const switchGround = switchOffGround(scope) ?? scope.background;
801
1074
  const rootHolds =
802
1075
  rootOn === null ||
803
1076
  fillLum === null ||
804
- contrastRatio(rootOn, fillLum) >= switchStateContrastRatio;
1077
+ (contrastRatio(rootOn, fillLum) >= switchStateContrastRatio &&
1078
+ inSwitchOffBand(base.switchTrackOff, switchGround) &&
1079
+ (isLightGround(scope.background) ? [] : [scope.color4, scope.color5]).every(
1080
+ (ground) => contrastRatio(rootOn, luminanceOf(ground) ?? rootOn) >= plateContrastRatio,
1081
+ ));
805
1082
  const track = rootHolds
806
1083
  ? undefined
807
1084
  : switchTrackPaint(
808
1085
  base,
809
1086
  fill,
810
- scope.background,
811
- isLightGround(scope.background) ? [] : [scope.color4],
1087
+ switchGround,
1088
+ isLightGround(scope.background) ? [] : [base.color4, scope.color4, scope.color5],
812
1089
  );
813
1090
  for (const [name, theme] of Object.entries(themes)) {
814
1091
  if (name === scopeName) {
@@ -943,6 +1220,41 @@ function holdHouseAccent(
943
1220
  return out;
944
1221
  }
945
1222
 
1223
+ /**
1224
+ * FZ-10073 with FZ-8400's dark overlay. The house root's identity ink is the
1225
+ * blue colour theme's. Judged on the neutral page alone, dark house picked
1226
+ * blue10 #3b9eff, which fails AA on blue's own `$color5` overlay, where blue
1227
+ * picks blue11 #70b8ff. A house theme (the scheme root, its components and
1228
+ * the intents that followed it) takes blue's ink when that ink also holds AA
1229
+ * on its own page surfaces, so the bar glyph is one blue in either scope.
1230
+ */
1231
+ function holdHouseIdentityInk(
1232
+ themes: Record<string, Record<string, string>>,
1233
+ ): Record<string, Record<string, string>> {
1234
+ const out: Record<string, Record<string, string>> = { ...themes };
1235
+ for (const scheme of ["light", "dark"] as const) {
1236
+ const base = themes[scheme];
1237
+ const blueInk = themes[`${scheme}_blue`]?.identityInk;
1238
+ if (!base?.identityInk || !blueInk || base.identityInk === blueInk) continue;
1239
+ const held = Array.from({ length: 12 }, (_, index) => index + 1).every(
1240
+ (step) =>
1241
+ base[`accent${step}`] !== undefined && base[`accent${step}`] === base[`blue${step}`],
1242
+ );
1243
+ if (!held) continue;
1244
+ for (const [name, theme] of Object.entries(themes)) {
1245
+ if (name !== scheme && !name.startsWith(`${scheme}_`)) continue;
1246
+ const scope = name.split("_")[1];
1247
+ if (scope === "accent" || (childrenHues as readonly string[]).includes(scope)) continue;
1248
+ if (theme.identityInk !== base.identityInk) continue;
1249
+ const values = resolveThemeThroughParents(themes, name);
1250
+ if (pickIdentityInk([blueInk], pageSurfaces(name, values)) === blueInk) {
1251
+ out[name] = { ...theme, identityInk: blueInk };
1252
+ }
1253
+ }
1254
+ }
1255
+ return out;
1256
+ }
1257
+
946
1258
  const defaultLightShadows: Shadows = {
947
1259
  shadow1: "rgba(0,0,0,0.04)",
948
1260
  shadow2: "rgba(0,0,0,0.08)",
@@ -1021,6 +1333,27 @@ function isChromatic(color: string | undefined): boolean {
1021
1333
  return Math.max(...channels) - Math.min(...channels) >= 24;
1022
1334
  }
1023
1335
 
1336
+ /**
1337
+ * Theme keys that only the post-passes in {@link createThemesBuilder} write
1338
+ * (the hold* passes, withAccentOnSurface, withIdentityInk). The `$token` lint
1339
+ * in public/config/lint.mjs reads this list, since it cannot run the passes,
1340
+ * and themeKeyRegistry.spec fails when a pass adds a key missing here.
1341
+ */
1342
+ export const postPassThemeKeys = [
1343
+ "accentOnSurface",
1344
+ "borderColorControl",
1345
+ "borderColorField",
1346
+ "borderColorFieldHover",
1347
+ "borderColorHairline",
1348
+ "identityInk",
1349
+ "selectedBackground",
1350
+ "selectedColor",
1351
+ "switchTrackOff",
1352
+ "switchTrackOffBorder",
1353
+ "switchTrackOffHover",
1354
+ "switchTrackOffPress",
1355
+ ] as const;
1356
+
1024
1357
  export function createThemesBuilder(
1025
1358
  baseTheme: BaseThemeBuilderDefinition,
1026
1359
  accentTheme: ThemeBuilderDefinition,
@@ -1090,18 +1423,24 @@ export function createThemesBuilder(
1090
1423
  const initialTheme = tamaguiCreateThemes(
1091
1424
  createThemesProps as Parameters<typeof tamaguiCreateThemes>[0],
1092
1425
  );
1093
- const themes = withIdentityInk(
1094
- withAccentOnSurface(
1095
- holdFocusRingAccent(
1096
- holdHouseAccent(
1097
- holdColorThemeAccent(
1098
- holdSwitchTrack(
1099
- holdSyntaxFloor(
1100
- holdNamedInkFloor(
1101
- holdSelectedState(
1102
- holdAccentFloor(
1103
- holdSelectionSurface(
1104
- neutralizeSemanticEntryText(neutralizeTintText(initialTheme)),
1426
+ const themes = holdHouseIdentityInk(
1427
+ withIdentityInk(
1428
+ withAccentOnSurface(
1429
+ holdFocusRingAccent(
1430
+ holdControlEdges(
1431
+ holdHouseAccent(
1432
+ holdColorThemeAccent(
1433
+ holdSwitchTrack(
1434
+ holdSyntaxFloor(
1435
+ holdNamedInkFloor(
1436
+ holdSelectedState(
1437
+ holdAccentFloor(
1438
+ holdDarkEdges(
1439
+ holdSelectionSurface(
1440
+ neutralizeSemanticEntryText(neutralizeTintText(initialTheme)),
1441
+ ),
1442
+ ),
1443
+ ),
1105
1444
  ),
1106
1445
  ),
1107
1446
  ),
@@ -1,5 +1,11 @@
1
1
  import type { CreateThemesBuilderOptions, GetThemeProps } from "../createThemes";
2
- import { contrastRatio, minContrastRatio, normalizeToHex, relativeLuminance } from "../colorRules";
2
+ import {
3
+ contrastRatio,
4
+ isLightGround,
5
+ minContrastRatio,
6
+ normalizeToHex,
7
+ relativeLuminance,
8
+ } from "../colorRules";
3
9
 
4
10
  /**
5
11
  * DG-A11Y-01 / DF-03. The external focus ring must clear 3:1 against both page
@@ -62,6 +68,14 @@ function pickBoundaryColor(theme: Record<string, string>): string {
62
68
  return best;
63
69
  }
64
70
 
71
+ /**
72
+ * LC-148 HAIRLINE-TOKEN in dark (FZ-9281): the least contrast a dark hairline
73
+ * holds against the page and against the grouped surface it divides. iOS's
74
+ * dark `opaqueSeparator` #38383A over `secondarySystemGroupedBackground`
75
+ * #1C1C1E measures 1.454:1. The medium edge sits one ramp step above it.
76
+ */
77
+ export const darkHairlineContrast = 1.454;
78
+
65
79
  /**
66
80
  * LC-148 HAIRLINE-TOKEN: `$borderColor` one ramp step toward the page. Where
67
81
  * the `small` border stop can't draw finer than 1px (web), the resolver paints
@@ -84,7 +98,8 @@ function pickHairlineBorderColor(
84
98
  * and the pick is the most muted ramp step that clears it. The stock v5 ramp
85
99
  * is not symmetric: light $color11 is 14% lightness, two points off the
86
100
  * primary ink, so one fixed step cannot read as secondary in both schemes.
87
- * On the default ramp this lands on $color9 in light and $color10 in dark.
101
+ * On the default ramp this lands on $color9 in light and $color11 in dark,
102
+ * where cards sit on $color4 (FZ-8400).
88
103
  */
89
104
  export const secondaryInkContrast = 7;
90
105
 
@@ -97,9 +112,10 @@ export function pickSecondaryInk(
97
112
  ramp: Record<string, string> = theme,
98
113
  ): string {
99
114
  const fallback = ramp.color11 ?? ramp.color12 ?? "transparent";
100
- const surfaces = [theme.background, theme.color1, theme.color2, theme.color3].map((raw) =>
101
- normalizeToHex(raw ?? ""),
102
- );
115
+ const tiers = [theme.background, theme.color1, theme.color2, theme.color3];
116
+ // Dark cards and fields sit on `$color4` (FZ-8400).
117
+ if (theme.background && !isLightGround(theme.background)) tiers.push(theme.color4);
118
+ const surfaces = tiers.map((raw) => normalizeToHex(raw ?? ""));
103
119
  if (surfaces.some((hex) => !hex)) return fallback;
104
120
  const luminances = surfaces.map((hex) => relativeLuminance(hex!));
105
121
  for (const key of [8, 9, 10, 11]) {
@@ -125,11 +141,12 @@ function getBaseTheme(theme: Record<string, string>) {
125
141
  textMuted: pickSecondaryInk(theme),
126
142
  textSubtle: theme.color10,
127
143
  borderStrong: theme.color7,
128
- borderStrongHover: theme.color8,
129
- borderStrongPress: theme.color6,
130
144
  surfaceStrong: theme.color7,
131
145
  surfaceMuted: theme.color10,
132
- surfacePressStrong: theme.color6,
146
+ // LC-05 selection fills (componentColors, formSelectionColors). A dark
147
+ // card re-points them one step above its own fill (createThemes).
148
+ selectFill: theme.color4,
149
+ selectPress: theme.color5,
133
150
  };
134
151
  }
135
152
 
@@ -14,17 +14,22 @@ const built = createThemesBuilder(
14
14
  ).themes() as Themes;
15
15
 
16
16
  const luminance = (raw: string) => relativeLuminance(normalizeToHex(raw)!);
17
+ // A dark card is `$color4` (LC-151, FZ-8400), so dark checks that tier too.
17
18
  const minContrast = (theme: Record<string, string>, ink: string) =>
18
19
  Math.min(
19
- ...[theme.background!, theme.color1!, theme.color2!, theme.color3!].map((surface) =>
20
- contrastRatio(luminance(ink), luminance(surface)),
21
- ),
20
+ ...[
21
+ theme.background!,
22
+ theme.color1!,
23
+ theme.color2!,
24
+ theme.color3!,
25
+ ...(luminance(theme.background!) < 0.18 ? [theme.color4!] : []),
26
+ ].map((surface) => contrastRatio(luminance(ink), luminance(surface))),
22
27
  );
23
28
 
24
29
  describe("LC-152 secondary ink", () => {
25
- it("lands on $color9 in light and $color10 in dark on the default ramp", () => {
30
+ it("lands on $color9 in light and $color11 in dark on the default ramp", () => {
26
31
  expect(built.light!.textMuted).toBe(built.light!.color9);
27
- expect(built.dark!.textMuted).toBe(built.dark!.color10);
32
+ expect(built.dark!.textMuted).toBe(built.dark!.color11);
28
33
  });
29
34
 
30
35
  it("clears 7:1 on the page and every card tier in both schemes", () => {
@@ -22,6 +22,7 @@ import {
22
22
  contrastRatio,
23
23
  fillLabelLc,
24
24
  inSwitchOffBand,
25
+ switchOffGround,
25
26
  normalizeToHex,
26
27
  relativeLuminance,
27
28
  } from "./colorRules";
@@ -133,9 +134,14 @@ describe("LC-172 the engine keeps every off toggle in the HIG band, a preset's o
133
134
  });
134
135
 
135
136
  for (const name of scopes) {
136
- it(`${name}: the off track sits in the system-fill band over the page`, () => {
137
+ it(`${name}: the off track sits in the system-fill band over its ground (the dark card)`, () => {
137
138
  const off = String(resolved[name].switchTrackOff);
138
- const ground = String(resolved[name].background);
139
+ const ground = String(
140
+ switchOffGround({
141
+ background: String(resolved[name].background),
142
+ color4: String(resolved[name].color4),
143
+ }),
144
+ );
139
145
  expect(inSwitchOffBand(off, ground), `${off} on ${ground}`).toBe(true);
140
146
  });
141
147
  }
@@ -1,5 +1,11 @@
1
1
  import { accentIdentityCandidates, resolveChartPalette } from "./chartPalette";
2
- import { apcaContrast, fillLabelLc, inSwitchOffBand, normalizeToHex } from "./colorRules";
2
+ import {
3
+ apcaContrast,
4
+ fillLabelLc,
5
+ inSwitchOffBand,
6
+ normalizeToHex,
7
+ switchOffGround,
8
+ } from "./colorRules";
3
9
  import { tintHueNames } from "./createThemes";
4
10
 
5
11
  export { apcaContrast };
@@ -129,7 +135,9 @@ export function auditPresetFills(
129
135
  }
130
136
  }
131
137
  const off = text(scope.switchTrackOff);
132
- const ground = text(scope.background);
138
+ const ground = text(
139
+ switchOffGround({ background: text(scope.background), color4: text(scope.color4) }),
140
+ );
133
141
  if (!off || !ground || !inSwitchOffBand(off, ground)) {
134
142
  findings.push({
135
143
  preset,
@@ -23,7 +23,7 @@ const housePresets = {
23
23
  storefront: storefrontPreset,
24
24
  };
25
25
 
26
- const page = { light: "#f7f7f7", dark: "#141414" } as const;
26
+ const page = { light: "#f7f7f7", dark: "#0a0a0a" } as const;
27
27
 
28
28
  const groundKeys = [
29
29
  "background",
@@ -25,6 +25,7 @@ export * from "./intents";
25
25
  export * from "./knobs";
26
26
  export * from "./layoutTokens";
27
27
  export * from "./pageGutter";
28
+ export * from "./tokenNumber";
28
29
  export * from "./Preset";
29
30
  export type { Preset as PresetDefinition, IntentOverride } from "./preset.types";
30
31
  export * from "./PresetContext";
@@ -50,9 +50,10 @@ export function useSchemePreference(): SchemePreference {
50
50
  getNativeScheme();
51
51
  Appearance.setColorScheme(setting === "system" ? "unspecified" : setting);
52
52
  }, [setting]);
53
+ const resolved = setting === "system" ? os : setting;
53
54
  return {
54
55
  setting: hydrated ? setting : "system",
55
- value: !hydrated ? "light" : setting === "system" ? os : setting,
56
+ value: hydrated ? resolved : "light",
56
57
  set: setStoredScheme,
57
58
  };
58
59
  }
@@ -47,7 +47,7 @@ export function ThemeProvider<T extends CreateTamaguiProps>({
47
47
  config,
48
48
  systemTheme,
49
49
  ...tamaguiProps
50
- }: ThemeProviderProps<T>) {
50
+ }: Readonly<ThemeProviderProps<T>>) {
51
51
  const scheme = useSchemePreference();
52
52
  const activeTheme = systemTheme ?? scheme.value;
53
53
  const ancestor = useContext(PresetContext);