react-native-terra-ui 0.7.2 → 0.8.1

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 (252) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/lib/module/components/avatar/Avatar.js +20 -20
  4. package/lib/module/components/avatar/Avatar.js.map +1 -1
  5. package/lib/module/components/button/Button.js +11 -11
  6. package/lib/module/components/button/Button.js.map +1 -1
  7. package/lib/module/components/chip/Chip.js +32 -32
  8. package/lib/module/components/chip/Chip.js.map +1 -1
  9. package/lib/module/components/empty-state/EmptyState.js +3 -3
  10. package/lib/module/components/empty-state/EmptyState.js.map +1 -1
  11. package/lib/module/components/header/variants/LargeTitleHeader/index.js +30 -32
  12. package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
  13. package/lib/module/components/header/variants/TitleHeader/index.js +26 -18
  14. package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
  15. package/lib/module/components/icon/Icon.js +3 -3
  16. package/lib/module/components/page-indicator/hooks/use-page-indicator-colors.js +2 -2
  17. package/lib/module/components/screen/Screen.js +63 -34
  18. package/lib/module/components/screen/Screen.js.map +1 -1
  19. package/lib/module/components/screen/ScreenContext.js +28 -9
  20. package/lib/module/components/screen/ScreenContext.js.map +1 -1
  21. package/lib/module/components/screen/ScreenFlatList.js +4 -162
  22. package/lib/module/components/screen/ScreenFlatList.js.map +1 -1
  23. package/lib/module/components/screen/ScreenList.js +81 -97
  24. package/lib/module/components/screen/ScreenList.js.map +1 -1
  25. package/lib/module/components/screen/ScreenScrollView.js +64 -115
  26. package/lib/module/components/screen/ScreenScrollView.js.map +1 -1
  27. package/lib/module/components/screen/hooks/use-screen-ref.js +18 -0
  28. package/lib/module/components/screen/hooks/use-screen-ref.js.map +1 -0
  29. package/lib/module/components/screen/hooks/use-screen-scroll-handler.js +19 -0
  30. package/lib/module/components/screen/hooks/use-screen-scroll-handler.js.map +1 -0
  31. package/lib/module/components/screen/index.js.map +1 -1
  32. package/lib/module/components/screen/parts/ScreenFrame.js +47 -0
  33. package/lib/module/components/screen/parts/ScreenFrame.js.map +1 -0
  34. package/lib/module/components/screen/parts/ScreenHeaderAppearance.js +43 -0
  35. package/lib/module/components/screen/parts/ScreenHeaderAppearance.js.map +1 -0
  36. package/lib/module/components/screen/parts/ScreenPlaceholder.js +11 -13
  37. package/lib/module/components/screen/parts/ScreenPlaceholder.js.map +1 -1
  38. package/lib/module/components/screen/parts/ScreenSafeArea.android.js +27 -0
  39. package/lib/module/components/screen/parts/ScreenSafeArea.android.js.map +1 -0
  40. package/lib/module/components/screen/parts/ScreenSafeArea.js +26 -0
  41. package/lib/module/components/screen/parts/ScreenSafeArea.js.map +1 -0
  42. package/lib/module/components/screen/utils.js +18 -9
  43. package/lib/module/components/screen/utils.js.map +1 -1
  44. package/lib/module/components/spinner/Spinner.js +2 -2
  45. package/lib/module/components/text/Text.js +1 -1
  46. package/lib/module/components/toast/Toast.js +13 -13
  47. package/lib/module/components/toast/Toast.js.map +1 -1
  48. package/lib/module/components/toolbar/Toolbar.js +2 -2
  49. package/lib/module/context/ThemeProvider.js +22 -39
  50. package/lib/module/context/ThemeProvider.js.map +1 -1
  51. package/lib/module/context/index.js +1 -1
  52. package/lib/module/context/index.js.map +1 -1
  53. package/lib/module/theme/breakpoints.js +1 -1
  54. package/lib/module/theme/define-config.js +21 -0
  55. package/lib/module/theme/define-config.js.map +1 -0
  56. package/lib/module/theme/diagnostics.js +13 -0
  57. package/lib/module/theme/diagnostics.js.map +1 -0
  58. package/lib/module/theme/errors.js +15 -0
  59. package/lib/module/theme/errors.js.map +1 -0
  60. package/lib/module/theme/index.js +7 -1
  61. package/lib/module/theme/index.js.map +1 -1
  62. package/lib/module/theme/legacy-tokens.js +119 -0
  63. package/lib/module/theme/legacy-tokens.js.map +1 -0
  64. package/lib/module/theme/registry.js +122 -130
  65. package/lib/module/theme/registry.js.map +1 -1
  66. package/lib/module/theme/resolve-config.js +346 -0
  67. package/lib/module/theme/resolve-config.js.map +1 -0
  68. package/lib/module/theme/runtime.js +57 -12
  69. package/lib/module/theme/runtime.js.map +1 -1
  70. package/lib/module/theme/screen-margin.js +6 -14
  71. package/lib/module/theme/screen-margin.js.map +1 -1
  72. package/lib/module/theme/screen-padding.js +17 -0
  73. package/lib/module/theme/screen-padding.js.map +1 -0
  74. package/lib/module/theme/selection-store.js +75 -0
  75. package/lib/module/theme/selection-store.js.map +1 -0
  76. package/lib/module/theme/theme.js +81 -36
  77. package/lib/module/theme/theme.js.map +1 -1
  78. package/lib/module/theme/tokens/canonical-color-keys.js +102 -0
  79. package/lib/module/theme/tokens/canonical-color-keys.js.map +1 -0
  80. package/lib/module/theme/tokens/dark.js +72 -88
  81. package/lib/module/theme/tokens/dark.js.map +1 -1
  82. package/lib/module/theme/tokens/light.js +69 -83
  83. package/lib/module/theme/tokens/light.js.map +1 -1
  84. package/lib/module/theme/tokens/primitives.js +14 -14
  85. package/lib/module/theme/tokens/primitives.js.map +1 -1
  86. package/lib/module/theme/tokens/validate.js +238 -0
  87. package/lib/module/theme/tokens/validate.js.map +1 -0
  88. package/lib/module/theme/unistyles-adapter.js +77 -0
  89. package/lib/module/theme/unistyles-adapter.js.map +1 -0
  90. package/lib/module/utils/accent-generator.js +476 -0
  91. package/lib/module/utils/accent-generator.js.map +1 -0
  92. package/lib/module/utils/accent-utils.js +52 -35
  93. package/lib/module/utils/accent-utils.js.map +1 -1
  94. package/lib/module/utils/color-utils.js +224 -7
  95. package/lib/module/utils/color-utils.js.map +1 -1
  96. package/lib/module/utils/deep-merge.js +1 -1
  97. package/lib/module/utils/deep-merge.js.map +1 -1
  98. package/lib/module/utils/resolve-theme-color.js +2 -2
  99. package/lib/typescript/src/components/chip/types.d.ts +1 -1
  100. package/lib/typescript/src/components/empty-state/EmptyState.d.ts +1 -1
  101. package/lib/typescript/src/components/empty-state/EmptyState.d.ts.map +1 -1
  102. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +6 -5
  103. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +11 -5
  105. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
  106. package/lib/typescript/src/components/icon/Icon.d.ts +2 -2
  107. package/lib/typescript/src/components/screen/Screen.d.ts +45 -16
  108. package/lib/typescript/src/components/screen/Screen.d.ts.map +1 -1
  109. package/lib/typescript/src/components/screen/ScreenContext.d.ts +42 -26
  110. package/lib/typescript/src/components/screen/ScreenContext.d.ts.map +1 -1
  111. package/lib/typescript/src/components/screen/ScreenFlatList.d.ts +5 -46
  112. package/lib/typescript/src/components/screen/ScreenFlatList.d.ts.map +1 -1
  113. package/lib/typescript/src/components/screen/ScreenList.d.ts +62 -50
  114. package/lib/typescript/src/components/screen/ScreenList.d.ts.map +1 -1
  115. package/lib/typescript/src/components/screen/ScreenScrollView.d.ts +18 -58
  116. package/lib/typescript/src/components/screen/ScreenScrollView.d.ts.map +1 -1
  117. package/lib/typescript/src/components/screen/hooks/use-screen-ref.d.ts +5 -0
  118. package/lib/typescript/src/components/screen/hooks/use-screen-ref.d.ts.map +1 -0
  119. package/lib/typescript/src/components/screen/hooks/use-screen-scroll-handler.d.ts +53 -0
  120. package/lib/typescript/src/components/screen/hooks/use-screen-scroll-handler.d.ts.map +1 -0
  121. package/lib/typescript/src/components/screen/index.d.ts +2 -2
  122. package/lib/typescript/src/components/screen/index.d.ts.map +1 -1
  123. package/lib/typescript/src/components/screen/parts/ScreenFrame.d.ts +6 -0
  124. package/lib/typescript/src/components/screen/parts/ScreenFrame.d.ts.map +1 -0
  125. package/lib/typescript/src/components/screen/parts/ScreenHeaderAppearance.d.ts +21 -0
  126. package/lib/typescript/src/components/screen/parts/ScreenHeaderAppearance.d.ts.map +1 -0
  127. package/lib/typescript/src/components/screen/parts/ScreenPlaceholder.d.ts +6 -8
  128. package/lib/typescript/src/components/screen/parts/ScreenPlaceholder.d.ts.map +1 -1
  129. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.android.d.ts +7 -0
  130. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.android.d.ts.map +1 -0
  131. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.d.ts +6 -0
  132. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.d.ts.map +1 -0
  133. package/lib/typescript/src/components/screen/types.d.ts +27 -32
  134. package/lib/typescript/src/components/screen/types.d.ts.map +1 -1
  135. package/lib/typescript/src/components/screen/utils.d.ts +9 -8
  136. package/lib/typescript/src/components/screen/utils.d.ts.map +1 -1
  137. package/lib/typescript/src/components/text/Text.d.ts +1 -1
  138. package/lib/typescript/src/components/text/Text.d.ts.map +1 -1
  139. package/lib/typescript/src/components/toolbar/Toolbar.d.ts +2 -2
  140. package/lib/typescript/src/context/ThemeProvider.d.ts +29 -12
  141. package/lib/typescript/src/context/ThemeProvider.d.ts.map +1 -1
  142. package/lib/typescript/src/context/index.d.ts +1 -1
  143. package/lib/typescript/src/context/index.d.ts.map +1 -1
  144. package/lib/typescript/src/theme/breakpoints.d.ts +1 -1
  145. package/lib/typescript/src/theme/define-config.d.ts +23 -0
  146. package/lib/typescript/src/theme/define-config.d.ts.map +1 -0
  147. package/lib/typescript/src/theme/diagnostics.d.ts +4 -0
  148. package/lib/typescript/src/theme/diagnostics.d.ts.map +1 -0
  149. package/lib/typescript/src/theme/errors.d.ts +10 -0
  150. package/lib/typescript/src/theme/errors.d.ts.map +1 -0
  151. package/lib/typescript/src/theme/index.d.ts +10 -2
  152. package/lib/typescript/src/theme/index.d.ts.map +1 -1
  153. package/lib/typescript/src/theme/legacy-tokens.d.ts +187 -0
  154. package/lib/typescript/src/theme/legacy-tokens.d.ts.map +1 -0
  155. package/lib/typescript/src/theme/registry.d.ts +12 -17
  156. package/lib/typescript/src/theme/registry.d.ts.map +1 -1
  157. package/lib/typescript/src/theme/resolve-config.d.ts +99 -0
  158. package/lib/typescript/src/theme/resolve-config.d.ts.map +1 -0
  159. package/lib/typescript/src/theme/runtime.d.ts +25 -4
  160. package/lib/typescript/src/theme/runtime.d.ts.map +1 -1
  161. package/lib/typescript/src/theme/screen-margin.d.ts +6 -14
  162. package/lib/typescript/src/theme/screen-margin.d.ts.map +1 -1
  163. package/lib/typescript/src/theme/screen-padding.d.ts +15 -0
  164. package/lib/typescript/src/theme/screen-padding.d.ts.map +1 -0
  165. package/lib/typescript/src/theme/selection-store.d.ts +41 -0
  166. package/lib/typescript/src/theme/selection-store.d.ts.map +1 -0
  167. package/lib/typescript/src/theme/theme.d.ts +54 -1
  168. package/lib/typescript/src/theme/theme.d.ts.map +1 -1
  169. package/lib/typescript/src/theme/tokens/canonical-color-keys.d.ts +95 -0
  170. package/lib/typescript/src/theme/tokens/canonical-color-keys.d.ts.map +1 -0
  171. package/lib/typescript/src/theme/tokens/dark.d.ts +56 -63
  172. package/lib/typescript/src/theme/tokens/dark.d.ts.map +1 -1
  173. package/lib/typescript/src/theme/tokens/light.d.ts +56 -71
  174. package/lib/typescript/src/theme/tokens/light.d.ts.map +1 -1
  175. package/lib/typescript/src/theme/tokens/primitives.d.ts +11 -10
  176. package/lib/typescript/src/theme/tokens/primitives.d.ts.map +1 -1
  177. package/lib/typescript/src/theme/tokens/validate.d.ts +34 -0
  178. package/lib/typescript/src/theme/tokens/validate.d.ts.map +1 -0
  179. package/lib/typescript/src/theme/types.d.ts +166 -86
  180. package/lib/typescript/src/theme/types.d.ts.map +1 -1
  181. package/lib/typescript/src/theme/unistyles-adapter.d.ts +33 -0
  182. package/lib/typescript/src/theme/unistyles-adapter.d.ts.map +1 -0
  183. package/lib/typescript/src/utils/accent-generator.d.ts +103 -0
  184. package/lib/typescript/src/utils/accent-generator.d.ts.map +1 -0
  185. package/lib/typescript/src/utils/accent-utils.d.ts +15 -2
  186. package/lib/typescript/src/utils/accent-utils.d.ts.map +1 -1
  187. package/lib/typescript/src/utils/color-utils.d.ts +59 -2
  188. package/lib/typescript/src/utils/color-utils.d.ts.map +1 -1
  189. package/lib/typescript/src/utils/deep-merge.d.ts +1 -0
  190. package/lib/typescript/src/utils/deep-merge.d.ts.map +1 -1
  191. package/lib/typescript/src/utils/resolve-theme-color.d.ts +2 -2
  192. package/package.json +1 -1
  193. package/src/components/avatar/Avatar.tsx +20 -20
  194. package/src/components/button/Button.tsx +11 -11
  195. package/src/components/chip/Chip.tsx +32 -32
  196. package/src/components/chip/types.ts +1 -1
  197. package/src/components/empty-state/EmptyState.tsx +4 -4
  198. package/src/components/header/variants/LargeTitleHeader/index.tsx +31 -30
  199. package/src/components/header/variants/TitleHeader/index.tsx +33 -22
  200. package/src/components/icon/Icon.tsx +4 -4
  201. package/src/components/page-indicator/hooks/use-page-indicator-colors.ts +2 -2
  202. package/src/components/screen/Screen.tsx +89 -43
  203. package/src/components/screen/ScreenContext.tsx +48 -28
  204. package/src/components/screen/ScreenFlatList.tsx +5 -191
  205. package/src/components/screen/ScreenList.tsx +197 -175
  206. package/src/components/screen/ScreenScrollView.tsx +92 -167
  207. package/src/components/screen/hooks/use-screen-ref.ts +24 -0
  208. package/src/components/screen/hooks/use-screen-scroll-handler.ts +31 -0
  209. package/src/components/screen/index.ts +8 -2
  210. package/src/components/screen/parts/ScreenFrame.tsx +49 -0
  211. package/src/components/screen/parts/ScreenHeaderAppearance.tsx +52 -0
  212. package/src/components/screen/parts/ScreenPlaceholder.tsx +16 -16
  213. package/src/components/screen/parts/ScreenSafeArea.android.tsx +22 -0
  214. package/src/components/screen/parts/ScreenSafeArea.tsx +20 -0
  215. package/src/components/screen/types.ts +29 -32
  216. package/src/components/screen/utils.ts +24 -11
  217. package/src/components/spinner/Spinner.tsx +2 -2
  218. package/src/components/text/Text.tsx +2 -2
  219. package/src/components/toast/Toast.tsx +13 -13
  220. package/src/components/toolbar/Toolbar.tsx +4 -4
  221. package/src/context/ThemeProvider.tsx +47 -51
  222. package/src/context/index.ts +1 -1
  223. package/src/theme/breakpoints.ts +1 -1
  224. package/src/theme/define-config.ts +27 -0
  225. package/src/theme/diagnostics.ts +11 -0
  226. package/src/theme/errors.ts +14 -0
  227. package/src/theme/index.ts +40 -1
  228. package/src/theme/legacy-tokens.ts +227 -0
  229. package/src/theme/registry.ts +119 -137
  230. package/src/theme/resolve-config.ts +406 -0
  231. package/src/theme/runtime.ts +52 -14
  232. package/src/theme/screen-margin.ts +6 -25
  233. package/src/theme/screen-padding.ts +26 -0
  234. package/src/theme/selection-store.ts +76 -0
  235. package/src/theme/theme.ts +104 -39
  236. package/src/theme/tokens/canonical-color-keys.ts +107 -0
  237. package/src/theme/tokens/dark.ts +91 -98
  238. package/src/theme/tokens/light.ts +88 -93
  239. package/src/theme/tokens/primitives.ts +14 -14
  240. package/src/theme/tokens/validate.ts +271 -0
  241. package/src/theme/types.ts +184 -95
  242. package/src/theme/unistyles-adapter.ts +75 -0
  243. package/src/utils/accent-generator.ts +530 -0
  244. package/src/utils/accent-utils.ts +66 -39
  245. package/src/utils/color-utils.ts +225 -8
  246. package/src/utils/deep-merge.ts +1 -1
  247. package/src/utils/resolve-theme-color.ts +2 -2
  248. package/lib/module/components/screen/parts/BottomSafeArea.js +0 -62
  249. package/lib/module/components/screen/parts/BottomSafeArea.js.map +0 -1
  250. package/lib/typescript/src/components/screen/parts/BottomSafeArea.d.ts +0 -30
  251. package/lib/typescript/src/components/screen/parts/BottomSafeArea.d.ts.map +0 -1
  252. package/src/components/screen/parts/BottomSafeArea.tsx +0 -59
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Pure, deterministic accent generation from a single seed color.
3
+ *
4
+ * Consumed by the resolver (`theme/resolve-config.ts`) for startup, runtime
5
+ * accent changes and `createTerraThemes`, and by the `normalizeAccent`
6
+ * compatibility wrapper. Must not import the registry, provider, Unistyles,
7
+ * or renderers — only `#theme/types` and the colour maths in `./color-utils`.
8
+ *
9
+ * ## Algorithm
10
+ *
11
+ * Every role is derived in OKLCH (Björn Ottosson's OKLab, in-house
12
+ * implementation in `./color-utils`): the seed's hue is kept for every role,
13
+ * chroma is capped per role and reduced further by sRGB gamut mapping, and
14
+ * perceptual lightness is *solved* per role against the scheme's actual
15
+ * ordinary surfaces so that WCAG 2.x targets are met (4.5:1 for text and
16
+ * solid/soft labels, 3:1 for the accent border). Contrast is evaluated with
17
+ * sRGB alpha compositing over each of the four surfaces and, for solid
18
+ * controls, additionally with the pressed opacity applied to the whole control.
19
+ *
20
+ * - `accent.solid.bg` keeps the seed exactly when black or white text reads on
21
+ * it at both opacities; otherwise the fill is moved the smallest lightness
22
+ * distance (ties prefer darker in light, lighter in dark) until a readable
23
+ * label exists — so the fill MAY differ from the seed. Pin `accent.solid.bg`
24
+ * through overrides for an exact fill.
25
+ * - `accent.solid.fg` is a tinted near-black or near-white (pure black/white
26
+ * when the tint would not reach 4.5:1) picked by contrast, not brightness.
27
+ * - Hover/pressed fills darken in light and lighten in dark (mirroring the
28
+ * built-in tokens); the direction flips only where following the scheme
29
+ * would break the label contrast or leave the lightness range.
30
+ * - `accent.soft.bg` is a light tint in light / dark tint in dark, placed
31
+ * relative to the surfaces; `accent.soft.fg` is solved on all three soft
32
+ * states. `accent.text` (and `text.link`, the existing shorthand) is solved
33
+ * on every surface; `accent.border` is solved to 3:1 on every surface.
34
+ * - Disabled fills are desaturated and low-contrast-but-visible, in the
35
+ * spirit of the default `slate.200`/`slate.700`-like tokens.
36
+ *
37
+ * Unsatisfiable pairs (e.g. one very light and one very dark surface) are
38
+ * reported as `unsatisfied` diagnostics with the best deterministic colour
39
+ * kept; unparseable surfaces are excluded from solving and reported as
40
+ * `unsupported`. Nothing ever falls back to white silently.
41
+ *
42
+ */
43
+ import type { CanonicalThemeColor, Scheme } from "#theme/types";
44
+ import {
45
+ compositedContrast,
46
+ type OKLCH,
47
+ oklchToRgb,
48
+ parseColor,
49
+ type RGB,
50
+ type RGBA,
51
+ rgbToHex,
52
+ rgbToOklch,
53
+ } from "./color-utils";
54
+
55
+ /** Every canonical role a seed generates. `text.link` is kept for the existing shorthand behaviour. */
56
+ export type GeneratedAccentRole =
57
+ | "accent.text"
58
+ | "accent.border"
59
+ | "accent.solid.bg"
60
+ | "accent.solid.bg.hover"
61
+ | "accent.solid.bg.pressed"
62
+ | "accent.solid.bg.disabled"
63
+ | "accent.solid.fg"
64
+ | "accent.soft.bg"
65
+ | "accent.soft.bg.hover"
66
+ | "accent.soft.bg.pressed"
67
+ | "accent.soft.bg.disabled"
68
+ | "accent.soft.fg"
69
+ | "text.link";
70
+
71
+ export const GENERATED_ACCENT_ROLES: readonly GeneratedAccentRole[] = [
72
+ "accent.text",
73
+ "accent.border",
74
+ "accent.solid.bg",
75
+ "accent.solid.bg.hover",
76
+ "accent.solid.bg.pressed",
77
+ "accent.solid.bg.disabled",
78
+ "accent.solid.fg",
79
+ "accent.soft.bg",
80
+ "accent.soft.bg.hover",
81
+ "accent.soft.bg.pressed",
82
+ "accent.soft.bg.disabled",
83
+ "accent.soft.fg",
84
+ "text.link",
85
+ ];
86
+
87
+ export type AccentSurfaceRole = "surface.canvas" | "surface.default" | "surface.raised" | "surface.sunken";
88
+
89
+ /** The four ordinary surfaces every accent pair is evaluated over, in a fixed order. */
90
+ export const ACCENT_SURFACE_ROLES: readonly AccentSurfaceRole[] = [
91
+ "surface.canvas",
92
+ "surface.default",
93
+ "surface.raised",
94
+ "surface.sunken",
95
+ ];
96
+
97
+ /**
98
+ * The resolved scheme context a generator needs: the scheme's actual ordinary
99
+ * surfaces (after defaults, shared and scheme patches) and the opacity that
100
+ * components apply to a whole pressed control.
101
+ */
102
+ export interface AccentGenerationContext {
103
+ scheme: Scheme;
104
+ surfaces: Record<AccentSurfaceRole, string>;
105
+ pressedOpacity: number;
106
+ }
107
+
108
+ /**
109
+ * `unsatisfied` — the pair was evaluated and fell below `required`.
110
+ * `unsupported` — a colour could not be evaluated (non-sRGB/opaque format);
111
+ * `ratio` is `null` and no accessibility claim is made.
112
+ */
113
+ export type ContrastDiagnosticKind = "unsatisfied" | "unsupported";
114
+
115
+ export interface ContrastDiagnostic {
116
+ kind: ContrastDiagnosticKind;
117
+ /** Foreground role checked, e.g. `accent.solid.fg` or `accent.text`. */
118
+ role: string;
119
+ /** Background role the foreground sits on, e.g. `accent.solid.bg` or `surface.canvas`. */
120
+ background: string;
121
+ /** Ordinary surface the pair was composited over. */
122
+ surface: AccentSurfaceRole;
123
+ /** Opacity applied to the whole control for this check (1 when none). */
124
+ opacity: number;
125
+ ratio: number | null;
126
+ required: number;
127
+ message: string;
128
+ }
129
+
130
+ export interface AccentGenerationResult {
131
+ colors: Record<GeneratedAccentRole, string>;
132
+ /** Pairs the generator could not satisfy or evaluate in the supported domain. */
133
+ diagnostics: ContrastDiagnostic[];
134
+ }
135
+
136
+ /** Whether `value` is an opaque six-digit sRGB hex seed (`#rrggbb`, case-insensitive). */
137
+ export function isSupportedAccentSeed(value: unknown): value is string {
138
+ return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim());
139
+ }
140
+
141
+ // ─── Targets and tuning ───────────────────────────────────────────────────────
142
+
143
+ /** WCAG 2.x minimum for normal text and solid/soft labels. */
144
+ export const ACCENT_TEXT_CONTRAST = 4.5;
145
+ /** WCAG 2.x minimum for a meaningful accent boundary on an ordinary surface. */
146
+ export const ACCENT_BORDER_CONTRAST = 3;
147
+
148
+ /** Lightness resolution of the per-role solver (OKLCH L units). */
149
+ const L_STEP = 0.005;
150
+
151
+ type Direction = 1 | -1;
152
+
153
+ interface SchemeTuning {
154
+ /** Scheme direction for hover/pressed and for role adjustments: darker in light, lighter in dark. */
155
+ dir: Direction;
156
+ /** Lightness step between the base fill and hover, and hover and pressed. */
157
+ solidStep: number;
158
+ /** Soft background lightness relative to the surfaces, with clamps. */
159
+ softBase: { offset: number; min: number; max: number };
160
+ /** Lightness step between soft base/hover/pressed (signed). */
161
+ softStep: number;
162
+ /** Chroma caps: soft tints stay pastel; disabled fills are near-neutral. */
163
+ softChroma: number;
164
+ solidDisabled: { l: number; c: number };
165
+ softDisabled: { l: number; c: number };
166
+ /** Tinted label lightness for the dark and light label polarity. */
167
+ darkLabelL: number;
168
+ lightLabelL: number;
169
+ /** Used to solve when no surface in the context can be evaluated. */
170
+ fallbackSurface: RGB;
171
+ }
172
+
173
+ const TUNING: Record<Scheme, SchemeTuning> = {
174
+ light: {
175
+ dir: -1,
176
+ solidStep: 0.06,
177
+ softBase: { offset: 0.01, min: 0.86, max: 0.965 },
178
+ softStep: -0.04,
179
+ softChroma: 0.08,
180
+ solidDisabled: { l: 0.93, c: 0.02 },
181
+ softDisabled: { l: 0.97, c: 0.01 },
182
+ darkLabelL: 0.15,
183
+ lightLabelL: 0.985,
184
+ fallbackSurface: { r: 255, g: 255, b: 255 },
185
+ },
186
+ dark: {
187
+ dir: 1,
188
+ solidStep: 0.06,
189
+ softBase: { offset: 0.03, min: 0.24, max: 0.5 },
190
+ softStep: 0.06,
191
+ softChroma: 0.06,
192
+ solidDisabled: { l: 0.4, c: 0.04 },
193
+ softDisabled: { l: 0.22, c: 0.01 },
194
+ darkLabelL: 0.15,
195
+ lightLabelL: 0.985,
196
+ fallbackSurface: { r: 0, g: 0, b: 0 },
197
+ },
198
+ };
199
+
200
+ const LABEL_CHROMA = 0.03;
201
+
202
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
203
+
204
+ const opaque = (rgb: RGB): RGBA => ({ ...rgb, a: 1 });
205
+
206
+ /** OKLCH → sRGB rounded to the 0-255 grid, so scores match the emitted hex exactly. */
207
+ const toRgb = (color: OKLCH): RGB => {
208
+ const rgb = oklchToRgb(color);
209
+ return { r: Math.round(rgb.r), g: Math.round(rgb.g), b: Math.round(rgb.b) };
210
+ };
211
+
212
+ /** A pressed opacity outside (0, 1] is not a usable multiplier; evaluate at 1 instead. */
213
+ function normalizeOpacity(value: number): number {
214
+ return Number.isFinite(value) && value > 0 && value <= 1 ? value : 1;
215
+ }
216
+
217
+ /** Parses a surface as an opaque sRGB colour; translucent or unparseable surfaces are not evaluable. */
218
+ function parseSurface(value: string): RGB | null {
219
+ const parsed = parseColor(value);
220
+ return parsed && parsed.a >= 1 ? parsed : null;
221
+ }
222
+
223
+ const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
224
+
225
+ /** Rounds to the solver grid so equal inputs always visit identical candidates. */
226
+ const snap = (l: number): number => Math.round(l / L_STEP) * L_STEP;
227
+
228
+ /**
229
+ * Yields candidate lightness values starting at `start`: only in `dir` when
230
+ * `both` is false, otherwise alternating outward with `dir` visited first at
231
+ * each distance. Stops once every remaining candidate is outside [0, 1].
232
+ */
233
+ function* lightnessCandidates(start: number, dir: Direction, both: boolean): Generator<number> {
234
+ const origin = clamp01(snap(start));
235
+ yield origin;
236
+ for (let k = 1; k <= Math.ceil(1 / L_STEP); k += 1) {
237
+ const forward = origin + dir * k * L_STEP;
238
+ const backward = origin - dir * k * L_STEP;
239
+ const forwardOk = forward >= -1e-9 && forward <= 1 + 1e-9;
240
+ const backwardOk = both && backward >= -1e-9 && backward <= 1 + 1e-9;
241
+ if (!forwardOk && !backwardOk) return;
242
+ if (forwardOk) yield clamp01(forward);
243
+ if (backwardOk) yield clamp01(backward);
244
+ }
245
+ }
246
+
247
+ interface Solved {
248
+ l: number;
249
+ rgb: RGB;
250
+ satisfied: boolean;
251
+ }
252
+
253
+ /**
254
+ * Finds the first candidate colour (hue/chroma fixed, lightness varied) whose
255
+ * `score` — min(ratio / required) over every pair it must satisfy — reaches 1.
256
+ * When nothing reaches 1, returns the best-scoring candidate (deterministic).
257
+ */
258
+ function solveLightness(
259
+ base: Pick<OKLCH, "c" | "h">,
260
+ start: number,
261
+ dir: Direction,
262
+ both: boolean,
263
+ score: (rgb: RGB) => number
264
+ ): Solved {
265
+ let best: Solved | null = null;
266
+ let bestScore = Number.NEGATIVE_INFINITY;
267
+ for (const l of lightnessCandidates(start, dir, both)) {
268
+ const rgb = toRgb({ l, c: base.c, h: base.h });
269
+ const s = score(rgb);
270
+ if (s >= 1) return { l, rgb, satisfied: true };
271
+ if (s > bestScore) {
272
+ bestScore = s;
273
+ best = { l, rgb, satisfied: false };
274
+ }
275
+ }
276
+ return (
277
+ best ?? { l: clamp01(start), rgb: toRgb({ l: clamp01(start), c: base.c, h: base.h }), satisfied: false }
278
+ );
279
+ }
280
+
281
+ const min = (values: number[]): number =>
282
+ values.reduce((acc, v) => Math.min(acc, v), Number.POSITIVE_INFINITY);
283
+
284
+ /** min over surfaces of contrast(fg on bg over surface) at each opacity, normalised by `required`. */
285
+ function pairScore(fg: RGBA, bgs: RGBA[], surfaces: RGB[], opacities: number[], required: number): number {
286
+ const ratios: number[] = [];
287
+ for (const bg of bgs) {
288
+ for (const surface of surfaces) {
289
+ for (const opacity of opacities) ratios.push(compositedContrast(fg, bg, surface, opacity) / required);
290
+ }
291
+ }
292
+ return min(ratios);
293
+ }
294
+
295
+ /** Contrast of a standalone colour (text/border) against each surface, normalised by `required`. */
296
+ function surfaceScore(color: RGB, surfaces: RGB[], required: number): number {
297
+ return min(
298
+ surfaces.map((surface) => compositedContrast(opaque(color), opaque(surface), surface, 1) / required)
299
+ );
300
+ }
301
+
302
+ // ─── Generation ───────────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Generates every {@link GeneratedAccentRole} for `seed` in the given scheme
306
+ * context. Pure and deterministic: same inputs → same output. Callers must
307
+ * validate the seed with {@link isSupportedAccentSeed} first.
308
+ */
309
+ export function generateAccentColors(seed: string, context: AccentGenerationContext): AccentGenerationResult {
310
+ if (!isSupportedAccentSeed(seed)) {
311
+ throw new Error(
312
+ `generateAccentColors: unsupported seed ${JSON.stringify(seed)}; expected an opaque #rrggbb sRGB hex.`
313
+ );
314
+ }
315
+ const seedHex = seed.trim().toLowerCase();
316
+ const seedRgb = parseColor(seedHex) as RGBA;
317
+ const seedLch = rgbToOklch(seedRgb);
318
+ const tuning = TUNING[context.scheme];
319
+ const { dir } = tuning;
320
+ const pressedOpacity = normalizeOpacity(context.pressedOpacity);
321
+ const opacities = pressedOpacity < 1 ? [1, pressedOpacity] : [1];
322
+
323
+ const evaluable = ACCENT_SURFACE_ROLES.map((role) => parseSurface(context.surfaces[role])).filter(
324
+ (surface): surface is RGB => surface !== null
325
+ );
326
+ const surfaces = evaluable.length > 0 ? evaluable : [tuning.fallbackSurface];
327
+ const surfaceLs = surfaces.map((surface) => rgbToOklch(surface).l);
328
+
329
+ const hue = { c: seedLch.c, h: seedLch.h };
330
+ const black: RGBA = { r: 0, g: 0, b: 0, a: 1 };
331
+ const white: RGBA = { r: 255, g: 255, b: 255, a: 1 };
332
+
333
+ // ── Solid fill: keep the seed when black or white reads on it at every opacity.
334
+ const fillScore = (fill: RGB, fg: RGBA): number =>
335
+ pairScore(fg, [opaque(fill)], surfaces, opacities, ACCENT_TEXT_CONTRAST);
336
+ const readable = (fill: RGB): boolean => fillScore(fill, black) >= 1 || fillScore(fill, white) >= 1;
337
+
338
+ let fillRgb: RGB = seedRgb;
339
+ let fillL = seedLch.l;
340
+ if (!readable(seedRgb)) {
341
+ const solved = solveLightness(hue, seedLch.l, dir, true, (rgb) =>
342
+ Math.max(fillScore(rgb, black), fillScore(rgb, white))
343
+ );
344
+ fillRgb = solved.rgb;
345
+ fillL = solved.l;
346
+ }
347
+ const fillHex = fillRgb === seedRgb ? seedHex : rgbToHex(fillRgb);
348
+
349
+ // Label polarity by contrast, never by perceived brightness.
350
+ const polarity: "dark" | "light" =
351
+ fillScore(fillRgb, black) >= fillScore(fillRgb, white) ? "dark" : "light";
352
+ const pureLabel = polarity === "dark" ? black : white;
353
+
354
+ // ── Solid states: follow the scheme, flip when that would break the label or leave [0, 1].
355
+ const statesFor = (direction: Direction): RGB[] =>
356
+ [1, 2].map((k) => toRgb({ l: clamp01(fillL + direction * k * tuning.solidStep), c: hue.c, h: hue.h }));
357
+ const statesFit = (direction: Direction): boolean => {
358
+ const far = fillL + direction * 2 * tuning.solidStep;
359
+ return far >= 0.06 && far <= 0.94;
360
+ };
361
+ const statesReadable = (states: RGB[]): boolean =>
362
+ pairScore(pureLabel, states.map(opaque), surfaces, opacities, ACCENT_TEXT_CONTRAST) >= 1;
363
+
364
+ const statesOk = (direction: Direction): boolean =>
365
+ statesFit(direction) && statesReadable(statesFor(direction));
366
+ const flipped = -dir as Direction;
367
+ let stateDir: Direction = dir;
368
+ if (!statesOk(dir)) {
369
+ // Prefer a direction that keeps the label readable; otherwise at least stay in range.
370
+ if (statesOk(flipped) || (!statesFit(dir) && statesFit(flipped))) stateDir = flipped;
371
+ }
372
+ const [hoverRgb, pressedRgb] = statesFor(stateDir) as [RGB, RGB];
373
+ const solidBgs = [fillRgb, hoverRgb, pressedRgb].map(opaque);
374
+
375
+ // ── Solid label: tinted near-black/near-white when it still reads everywhere, else pure.
376
+ const tinted = opaque(
377
+ toRgb({
378
+ l: polarity === "dark" ? tuning.darkLabelL : tuning.lightLabelL,
379
+ c: Math.min(seedLch.c, LABEL_CHROMA),
380
+ h: seedLch.h,
381
+ })
382
+ );
383
+ const solidFg =
384
+ pairScore(tinted, solidBgs, surfaces, opacities, ACCENT_TEXT_CONTRAST) >= 1 ? tinted : pureLabel;
385
+
386
+ // ── Standalone text and border against every surface.
387
+ const text = solveLightness(hue, seedLch.l, dir, false, (rgb) =>
388
+ surfaceScore(rgb, surfaces, ACCENT_TEXT_CONTRAST)
389
+ );
390
+ const border = solveLightness(hue, seedLch.l, dir, false, (rgb) =>
391
+ surfaceScore(rgb, surfaces, ACCENT_BORDER_CONTRAST)
392
+ );
393
+
394
+ // ── Soft tint relative to the surfaces, with its own solved foreground.
395
+ const softAnchor = dir === -1 ? min(surfaceLs) : Math.max(...surfaceLs);
396
+ const softL = Math.max(
397
+ tuning.softBase.min,
398
+ Math.min(tuning.softBase.max, softAnchor + tuning.softBase.offset)
399
+ );
400
+ const softChroma = Math.min(seedLch.c, tuning.softChroma);
401
+ const softBgs = [0, 1, 2].map((k) =>
402
+ toRgb({ l: clamp01(softL + k * tuning.softStep), c: softChroma, h: seedLch.h })
403
+ );
404
+ const softFg = solveLightness(hue, text.l, dir, false, (rgb) =>
405
+ pairScore(opaque(rgb), softBgs.map(opaque), surfaces, [1], ACCENT_TEXT_CONTRAST)
406
+ );
407
+
408
+ // ── Disabled: desaturated, low-contrast-but-visible.
409
+ const solidDisabled = toRgb({
410
+ l: tuning.solidDisabled.l,
411
+ c: Math.min(seedLch.c, tuning.solidDisabled.c),
412
+ h: seedLch.h,
413
+ });
414
+ const softDisabled = toRgb({
415
+ l: tuning.softDisabled.l,
416
+ c: Math.min(seedLch.c, tuning.softDisabled.c),
417
+ h: seedLch.h,
418
+ });
419
+
420
+ const textHex = rgbToHex(text.rgb);
421
+ const colors: Record<GeneratedAccentRole, string> = {
422
+ "accent.text": textHex,
423
+ "accent.border": rgbToHex(border.rgb),
424
+ "accent.solid.bg": fillHex,
425
+ "accent.solid.bg.hover": rgbToHex(hoverRgb),
426
+ "accent.solid.bg.pressed": rgbToHex(pressedRgb),
427
+ "accent.solid.bg.disabled": rgbToHex(solidDisabled),
428
+ "accent.solid.fg": rgbToHex(solidFg),
429
+ "accent.soft.bg": rgbToHex(softBgs[0] as RGB),
430
+ "accent.soft.bg.hover": rgbToHex(softBgs[1] as RGB),
431
+ "accent.soft.bg.pressed": rgbToHex(softBgs[2] as RGB),
432
+ "accent.soft.bg.disabled": rgbToHex(softDisabled),
433
+ "accent.soft.fg": rgbToHex(softFg.rgb),
434
+ "text.link": textHex,
435
+ };
436
+
437
+ return { colors, diagnostics: checkAccentContrast(colors, context) };
438
+ }
439
+
440
+ // ─── Diagnostics ──────────────────────────────────────────────────────────────
441
+
442
+ interface ContrastPair {
443
+ role: GeneratedAccentRole;
444
+ /** A generated role used as background, or the surface itself. */
445
+ background: GeneratedAccentRole | "surface";
446
+ opacity: number;
447
+ required: number;
448
+ }
449
+
450
+ function contrastPairs(pressedOpacity: number): ContrastPair[] {
451
+ const pairs: ContrastPair[] = [];
452
+ const opacities = pressedOpacity < 1 ? [1, pressedOpacity] : [1];
453
+ for (const background of ["accent.solid.bg", "accent.solid.bg.hover", "accent.solid.bg.pressed"] as const) {
454
+ for (const opacity of opacities) {
455
+ pairs.push({ role: "accent.solid.fg", background, opacity, required: ACCENT_TEXT_CONTRAST });
456
+ }
457
+ }
458
+ for (const background of ["accent.soft.bg", "accent.soft.bg.hover", "accent.soft.bg.pressed"] as const) {
459
+ pairs.push({ role: "accent.soft.fg", background, opacity: 1, required: ACCENT_TEXT_CONTRAST });
460
+ }
461
+ pairs.push({ role: "accent.text", background: "surface", opacity: 1, required: ACCENT_TEXT_CONTRAST });
462
+ pairs.push({ role: "text.link", background: "surface", opacity: 1, required: ACCENT_TEXT_CONTRAST });
463
+ pairs.push({ role: "accent.border", background: "surface", opacity: 1, required: ACCENT_BORDER_CONTRAST });
464
+ return pairs;
465
+ }
466
+
467
+ const show = (value: unknown): string => (typeof value === "string" ? JSON.stringify(value) : String(value));
468
+
469
+ /**
470
+ * Evaluates the final accent roles (after explicit overrides) against their
471
+ * intended backgrounds in the supported domain and returns the failing or
472
+ * unevaluable pairs. Never repairs a colour.
473
+ */
474
+ export function checkAccentContrast(
475
+ colors: Pick<CanonicalThemeColor, GeneratedAccentRole>,
476
+ context: AccentGenerationContext
477
+ ): ContrastDiagnostic[] {
478
+ const diagnostics: ContrastDiagnostic[] = [];
479
+ const pairs = contrastPairs(normalizeOpacity(context.pressedOpacity));
480
+ const values = colors as unknown as Record<string, unknown>;
481
+
482
+ for (const surfaceRole of ACCENT_SURFACE_ROLES) {
483
+ const surfaceValue = context.surfaces[surfaceRole];
484
+ const surface = parseSurface(surfaceValue);
485
+
486
+ for (const pair of pairs) {
487
+ const backgroundRole = pair.background === "surface" ? surfaceRole : pair.background;
488
+ const where = `${pair.role} on ${backgroundRole} over ${surfaceRole} at opacity ${pair.opacity}`;
489
+ const fgValue = values[pair.role];
490
+ const fg = parseColor(fgValue);
491
+ const bgValue = pair.background === "surface" ? surfaceValue : values[pair.background];
492
+ const bg = pair.background === "surface" ? (surface ? opaque(surface) : null) : parseColor(bgValue);
493
+
494
+ const unsupported: string[] = [];
495
+ if (!surface) unsupported.push(`${surfaceRole} ${show(surfaceValue)} is not an opaque sRGB colour`);
496
+ if (!fg) unsupported.push(`${pair.role} ${show(fgValue)} is not an sRGB colour`);
497
+ if (!bg && pair.background !== "surface") {
498
+ unsupported.push(`${pair.background} ${show(bgValue)} is not an sRGB colour`);
499
+ }
500
+ if (!surface || !fg || !bg) {
501
+ diagnostics.push({
502
+ kind: "unsupported",
503
+ role: pair.role,
504
+ background: backgroundRole,
505
+ surface: surfaceRole,
506
+ opacity: pair.opacity,
507
+ ratio: null,
508
+ required: pair.required,
509
+ message: `${where} cannot be evaluated: ${unsupported.join("; ")}.`,
510
+ });
511
+ continue;
512
+ }
513
+
514
+ const ratio = compositedContrast(fg, bg, surface, pair.opacity);
515
+ if (ratio < pair.required) {
516
+ diagnostics.push({
517
+ kind: "unsatisfied",
518
+ role: pair.role,
519
+ background: backgroundRole,
520
+ surface: surfaceRole,
521
+ opacity: pair.opacity,
522
+ ratio,
523
+ required: pair.required,
524
+ message: `${where} is ${ratio.toFixed(2)}:1, below the ${pair.required}:1 target.`,
525
+ });
526
+ }
527
+ }
528
+ }
529
+ return diagnostics;
530
+ }
@@ -1,49 +1,76 @@
1
- import type { Accent, AccentShorthand, NormalizedAccent, TerraTheme } from "#theme/types";
2
- import { readableOn, shade, withAlpha } from "./color-utils";
1
+ import { normalizeTokenOverride } from "#theme/legacy-tokens";
2
+ import { normalizeAccentScheme } from "#theme/resolve-config";
3
+ import { defaultDarkTheme, defaultLightTheme } from "#theme/theme";
4
+ import type {
5
+ Accent,
6
+ AccentSchemeInput,
7
+ NormalizedAccent,
8
+ Scheme,
9
+ TerraTheme,
10
+ ThemeColor,
11
+ } from "#theme/types";
12
+ import {
13
+ ACCENT_SURFACE_ROLES,
14
+ type AccentGenerationContext,
15
+ type AccentSurfaceRole,
16
+ generateAccentColors,
17
+ } from "./accent-generator";
3
18
  import type { DeepPartial } from "./deep-merge";
4
19
 
5
- const isShorthand = (accent: Accent): accent is AccentShorthand =>
6
- typeof (accent as AccentShorthand).light === "string" ||
7
- typeof (accent as AccentShorthand).dark === "string";
20
+ /**
21
+ * The generation context of a built-in default theme: its four ordinary
22
+ * surfaces and pressed opacity. Used by the {@link normalizeAccent}
23
+ * compatibility wrapper, which has no access to shared/scheme patches.
24
+ */
25
+ export function defaultAccentContext(scheme: Scheme): AccentGenerationContext {
26
+ const theme = scheme === "light" ? defaultLightTheme : defaultDarkTheme;
27
+ const surfaces = {} as Record<AccentSurfaceRole, string>;
28
+ for (const role of ACCENT_SURFACE_ROLES) surfaces[role] = theme.color[role];
29
+ return { scheme, surfaces, pressedOpacity: theme.opacity.pressed };
30
+ }
8
31
 
9
32
  /**
10
- * Expands a single hue into a fuller per-scheme patch so primary AND subtle
11
- * actions (plus accent text) all recolor consistently:
12
- * - primary: solid hue with derived hover/active and a legible foreground,
13
- * - subtle: soft translucent tint of the hue with the hue as foreground,
14
- * - text.accent / text.link / text.on-accent.
15
- *
16
- * On `light` the hover/active darken; on `dark` they lighten.
33
+ * Generated accent colours for `seed` with `overrides` applied last. Within
34
+ * the override patch canonical names beat their legacy aliases, while an
35
+ * alias-only key still beats the generated canonical value.
17
36
  */
18
- function hueToPartial(hue: string | undefined, scheme: "light" | "dark"): DeepPartial<TerraTheme> {
19
- if (!hue) return {};
20
- const dir = scheme === "light" ? -1 : 1;
21
- const isLight = scheme === "light";
22
- const fg = readableOn(hue);
23
- // Flat color keys to patch — deepMerge replaces string leaves directly.
24
- const color = {
25
- "action.bg.primary": hue,
26
- "action.fg.primary": fg,
27
- "action.bg.primary.hover": shade(hue, dir * 0.12),
28
- "action.bg.primary.active": shade(hue, dir * 0.24),
29
- "action.bg.primary.subtle": withAlpha(hue, isLight ? 0.12 : 0.2),
30
- "action.fg.primary.subtle": hue,
31
- "action.bg.primary.subtle.hover": withAlpha(hue, isLight ? 0.18 : 0.28),
32
- "action.bg.primary.subtle.active": withAlpha(hue, isLight ? 0.24 : 0.36),
33
- "text.accent": hue,
34
- "text.link": hue,
35
- "text.on-accent": fg,
36
- };
37
+ function seedToColorPatch(
38
+ seed: string,
39
+ overrides: Partial<ThemeColor> | undefined,
40
+ scheme: Scheme
41
+ ): DeepPartial<TerraTheme> {
42
+ const generated = generateAccentColors(seed, defaultAccentContext(scheme)).colors;
43
+ const color: Record<string, string> = { ...generated };
44
+ const normalized = normalizeTokenOverride({ color: overrides })?.color;
45
+ if (normalized) {
46
+ for (const [key, value] of Object.entries(normalized)) {
47
+ if (value !== undefined) color[key] = value as string;
48
+ }
49
+ }
37
50
  return { color } as unknown as DeepPartial<TerraTheme>;
38
51
  }
39
52
 
40
- /** Normalizes any accent input into per-scheme partial-theme overrides. */
41
- export function normalizeAccent(accent: Accent): NormalizedAccent {
42
- if (isShorthand(accent)) {
43
- return {
44
- light: hueToPartial(accent.light, "light"),
45
- dark: hueToPartial(accent.dark, "dark"),
46
- };
53
+ function normalizeSchemeInput(input: AccentSchemeInput | undefined, scheme: Scheme): DeepPartial<TerraTheme> {
54
+ const normalized = normalizeAccentScheme(input, `accents.<accent>.${scheme}`);
55
+ if (normalized.kind === "none") return {};
56
+ if (normalized.kind === "seed") {
57
+ return seedToColorPatch(normalized.seed, normalized.overrides, scheme);
47
58
  }
48
- return { light: accent.light ?? {}, dark: accent.dark ?? {} };
59
+ // Preserve the compatibility helper's identity contract for full patches;
60
+ // the resolver itself uses the sanitized copy in `normalized.patch`.
61
+ return input as DeepPartial<TerraTheme>;
62
+ }
63
+
64
+ /**
65
+ * Compatibility wrapper: normalizes any accent input into per-scheme
66
+ * partial-theme overrides. Strings and `{ seed, overrides }` are generated
67
+ * against the DEFAULT themes' surfaces and pressed opacity (the resolver
68
+ * pipeline uses the actual resolved surfaces instead); full theme patches
69
+ * pass through unchanged. Pure and independent of the registry.
70
+ */
71
+ export function normalizeAccent(accent: Accent): NormalizedAccent {
72
+ return {
73
+ light: normalizeSchemeInput(accent.light, "light"),
74
+ dark: normalizeSchemeInput(accent.dark, "dark"),
75
+ };
49
76
  }