@titan-design/react-ui 0.5.0 → 0.7.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 (307) hide show
  1. package/dist/bodymap.js +241 -189
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +241 -189
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +1017 -130
  6. package/dist/index.d.ts +1017 -130
  7. package/dist/index.js +3058 -1090
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2992 -1091
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-MO0JCySL.d.mts → pages-8u_4WbL-.d.mts} +280 -57
  12. package/dist/{pages-D7Jlssvp.d.ts → pages-DaWiBOGa.d.ts} +280 -57
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1797 -1292
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1797 -1292
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +464 -268
  20. package/dist/theme/index.d.ts +464 -268
  21. package/dist/theme/index.js +303 -191
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +295 -192
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +160 -143
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +160 -143
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +3 -1
  30. package/src/arch/Architecture.stories.tsx +870 -0
  31. package/src/arch/annotations.json +48 -0
  32. package/src/arch/arch-graph.json +4301 -0
  33. package/src/components/custom/DateTime/DateTime.stories.tsx +83 -7
  34. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  35. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  36. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  37. package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
  38. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  39. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  40. package/src/components/custom/Metric/Metric.stories.tsx +1 -1
  41. package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
  42. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  43. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  44. package/src/components/custom/Table/Table.stories.tsx +1 -1
  45. package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
  46. package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
  47. package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
  48. package/src/components/custom/TimerReadout/index.ts +1 -0
  49. package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
  50. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  51. package/src/components/custom/Typography/Typography.stories.tsx +46 -28
  52. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  53. package/src/components/custom/Typography/Typography.tsx +5 -0
  54. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
  55. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
  56. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +11 -16
  57. package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
  58. package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
  59. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  60. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  67. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
  70. package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
  71. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
  72. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
  73. package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
  74. package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
  75. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
  76. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
  77. package/src/components/custom/Workout/ExerciseDetailPage.tsx +6 -6
  78. package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
  79. package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
  80. package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
  81. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
  82. package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
  83. package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
  84. package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
  85. package/src/components/custom/Workout/FatigueMeter.test.tsx +73 -0
  86. package/src/components/custom/Workout/FatigueMeter.tsx +82 -0
  87. package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
  88. package/src/components/custom/Workout/InputBar.tsx +2 -1
  89. package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
  90. package/src/components/custom/Workout/IntensityBar.test.tsx +8 -8
  91. package/src/components/custom/Workout/IntensityBar.tsx +10 -6
  92. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
  93. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
  94. package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
  95. package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
  96. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  97. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
  98. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  99. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  100. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
  101. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  102. package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
  103. package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
  104. package/src/components/custom/Workout/MetricTiles.tsx +48 -0
  105. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
  106. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  107. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
  108. package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
  109. package/src/components/custom/Workout/PrBadge.tsx +5 -2
  110. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
  111. package/src/components/custom/Workout/PrHistoryModal.tsx +4 -3
  112. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
  113. package/src/components/custom/Workout/ProgramPlanningPage.tsx +10 -7
  114. package/src/components/custom/Workout/README.md +146 -4
  115. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
  116. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  117. package/src/components/custom/Workout/RestTimer.stories.tsx +1 -1
  118. package/src/components/custom/Workout/RestTimer.tsx +17 -11
  119. package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
  120. package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
  121. package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
  122. package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
  123. package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
  124. package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
  125. package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
  126. package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
  127. package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
  128. package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
  129. package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
  130. package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
  131. package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
  132. package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
  133. package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
  134. package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
  135. package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
  136. package/src/components/custom/Workout/SessionHeader.tsx +152 -0
  137. package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
  138. package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
  139. package/src/components/custom/Workout/SessionRail.tsx +126 -0
  140. package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
  141. package/src/components/custom/Workout/SetBar.test.tsx +123 -0
  142. package/src/components/custom/Workout/SetBar.tsx +171 -0
  143. package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
  144. package/src/components/custom/Workout/SetRow.test.tsx +94 -166
  145. package/src/components/custom/Workout/SetRow.tsx +170 -232
  146. package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
  147. package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
  148. package/src/components/custom/Workout/SetStrip.tsx +71 -0
  149. package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
  150. package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
  151. package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
  152. package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
  153. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
  154. package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
  155. package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
  156. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  157. package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
  158. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  159. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  160. package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
  161. package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
  162. package/src/components/custom/Workout/StatusPill.tsx +88 -0
  163. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
  164. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  165. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
  166. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  167. package/src/components/custom/Workout/TempoBar.stories.tsx +1 -1
  168. package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
  169. package/src/components/custom/Workout/TempoDisplay.tsx +29 -36
  170. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
  171. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
  172. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
  173. package/src/components/custom/Workout/VelocityStrip.stories.tsx +129 -66
  174. package/src/components/custom/Workout/VelocityStrip.test.tsx +379 -144
  175. package/src/components/custom/Workout/VelocityStrip.tsx +508 -120
  176. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
  177. package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
  178. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
  179. package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
  180. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  181. package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
  182. package/src/components/custom/Workout/WeightBadge.tsx +6 -2
  183. package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
  184. package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
  185. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  186. package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
  187. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  188. package/src/components/custom/Workout/ZoneTrack.stories.tsx +140 -0
  189. package/src/components/custom/Workout/ZoneTrack.test.tsx +200 -0
  190. package/src/components/custom/Workout/ZoneTrack.tsx +315 -0
  191. package/src/components/custom/Workout/icons.tsx +6 -67
  192. package/src/components/custom/Workout/index.ts +55 -2
  193. package/src/components/custom/Workout/metricText.stories.tsx +69 -0
  194. package/src/components/custom/Workout/metricText.tsx +34 -0
  195. package/src/components/custom/Workout/paceTone.test.ts +29 -0
  196. package/src/components/custom/Workout/paceTone.ts +27 -0
  197. package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
  198. package/src/components/custom/index.ts +1 -0
  199. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  200. package/src/components/icons/SvgIcon.tsx +50 -0
  201. package/src/components/icons/icons.stories.tsx +122 -0
  202. package/src/components/icons/icons.test.tsx +73 -0
  203. package/src/components/icons/icons.tsx +153 -0
  204. package/src/components/icons/index.ts +4 -0
  205. package/src/components/index.ts +6 -0
  206. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  207. package/src/components/shell/BrandLockup.test.tsx +23 -0
  208. package/src/components/shell/BrandLockup.tsx +47 -0
  209. package/src/components/shell/DashboardShell.stories.tsx +65 -0
  210. package/src/components/shell/DashboardShell.test.tsx +42 -0
  211. package/src/components/shell/DashboardShell.tsx +74 -0
  212. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  213. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  214. package/src/components/shell/DeviceIndicator.tsx +52 -0
  215. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  216. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  217. package/src/components/shell/DeviceMenu.tsx +65 -0
  218. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  219. package/src/components/shell/DeviceRow.test.tsx +37 -0
  220. package/src/components/shell/DeviceRow.tsx +58 -0
  221. package/src/components/shell/NavItem.stories.tsx +52 -0
  222. package/src/components/shell/NavItem.test.tsx +35 -0
  223. package/src/components/shell/NavItem.tsx +75 -0
  224. package/src/components/shell/README.md +116 -0
  225. package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
  226. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  227. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  228. package/src/components/shell/SessionStatePill.tsx +46 -0
  229. package/src/components/shell/SideNav.stories.tsx +81 -0
  230. package/src/components/shell/SideNav.test.tsx +51 -0
  231. package/src/components/shell/SideNav.tsx +74 -0
  232. package/src/components/shell/TopBar.stories.tsx +49 -0
  233. package/src/components/shell/TopBar.test.tsx +36 -0
  234. package/src/components/shell/TopBar.tsx +89 -0
  235. package/src/components/shell/index.ts +13 -0
  236. package/src/components/ui/alert/Alert.stories.tsx +1 -1
  237. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  238. package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
  239. package/src/components/ui/badge/Badge.stories.tsx +1 -1
  240. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
  241. package/src/components/ui/button/Button.stories.tsx +1 -1
  242. package/src/components/ui/card/Card.stories.tsx +1 -1
  243. package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
  244. package/src/components/ui/chip/Chip.stories.tsx +1 -1
  245. package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
  246. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  247. package/src/components/ui/divider/Divider.stories.tsx +1 -1
  248. package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
  249. package/src/components/ui/form-field/FormField.stories.tsx +1 -1
  250. package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
  251. package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
  252. package/src/components/ui/index.ts +1 -0
  253. package/src/components/ui/indicator/Indicator.stories.tsx +38 -2
  254. package/src/components/ui/indicator/Indicator.test.tsx +20 -1
  255. package/src/components/ui/indicator/Indicator.tsx +50 -6
  256. package/src/components/ui/indicator/index.ts +1 -1
  257. package/src/components/ui/input/Input.stories.tsx +1 -1
  258. package/src/components/ui/link/Link.stories.tsx +1 -1
  259. package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
  260. package/src/components/ui/menu/Menu.stories.tsx +1 -1
  261. package/src/components/ui/modal/Modal.stories.tsx +1 -1
  262. package/src/components/ui/pill/Pill.stories.tsx +1 -1
  263. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  264. package/src/components/ui/progress/Progress.stories.tsx +1 -1
  265. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  266. package/src/components/ui/section/Section.stories.tsx +1 -1
  267. package/src/components/ui/select/Select.stories.tsx +1 -1
  268. package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
  269. package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
  270. package/src/components/ui/spinner/Spinner.tsx +3 -2
  271. package/src/components/ui/stack/Stack.stories.tsx +1 -1
  272. package/src/components/ui/surface/Surface.stories.tsx +1 -1
  273. package/src/components/ui/switch/Switch.stories.tsx +1 -1
  274. package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
  275. package/src/components/ui/tile/Tile.stories.tsx +79 -0
  276. package/src/components/ui/tile/Tile.test.tsx +48 -0
  277. package/src/components/ui/tile/Tile.tsx +64 -0
  278. package/src/components/ui/tile/index.ts +2 -0
  279. package/src/components/ui/toast/Toast.stories.tsx +1 -1
  280. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  281. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  282. package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
  283. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
  284. package/src/hooks/index.ts +2 -0
  285. package/src/hooks/useTimer.test.ts +150 -0
  286. package/src/hooks/useTimer.ts +77 -0
  287. package/src/index.ts +3 -0
  288. package/src/pages.ts +1 -1
  289. package/src/stories/PresetShowcase.stories.tsx +1 -1
  290. package/src/stories/ThemePresets.stories.tsx +1 -1
  291. package/src/theme/ColorSystem.stories.tsx +323 -0
  292. package/src/theme/Colors.stories.tsx +74 -9
  293. package/src/theme/barrel.ts +1 -0
  294. package/src/theme/config.ts +20 -3
  295. package/src/theme/elevation.stories.tsx +1 -1
  296. package/src/theme/extracted-colors-dataviz.ts +21 -0
  297. package/src/theme/extracted-colors-ui.ts +20 -0
  298. package/src/theme/global.css +69 -53
  299. package/src/theme/gradients.test.ts +25 -0
  300. package/src/theme/gradients.ts +36 -0
  301. package/src/theme/shadows.stories.tsx +1 -1
  302. package/src/theme/tokens/primitives.test.ts +232 -0
  303. package/src/theme/tokens/primitives.ts +238 -87
  304. package/src/theme/tokens/semantic.ts +101 -81
  305. package/src/theme/workout-tokens.ts +26 -21
  306. package/src/utils/avatar-color.ts +3 -3
  307. package/tailwind.config.js +15 -3
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ primitiveRamps,
4
+ categoricalPalette,
5
+ getCategoricalColor,
6
+ CATEGORICAL_CVD_SAFE_MAX,
7
+ divergingScale,
8
+ sequentialEffort,
9
+ bestTextColor,
10
+ } from './primitives'
11
+
12
+ // --- Minimal OKLab + Machado-2009 dichromacy math (ported from the color-system
13
+ // derivation tools) so the categorical CVD floor is a real, checked property. ---
14
+ const hexToRgb = (hex: string): [number, number, number] => {
15
+ const h = hex.replace('#', '')
16
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
17
+ }
18
+ const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
19
+ const toOklab = ([r, g, b]: number[]): [number, number, number] => {
20
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
21
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
22
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
23
+ return [
24
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
25
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
26
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
27
+ ]
28
+ }
29
+ const mul = (M: number[], v: number[]) => [
30
+ M[0] * v[0] + M[1] * v[1] + M[2] * v[2],
31
+ M[3] * v[0] + M[4] * v[1] + M[5] * v[2],
32
+ M[6] * v[0] + M[7] * v[1] + M[8] * v[2],
33
+ ]
34
+ // Machado-2009 dichromacy simulation matrices, severity 1.0. The palette solver
35
+ // optimizes worst-case deuteranopia/protanopia (red-green is the binding case) —
36
+ // match that 2-way metric here, not a stricter 3-way including tritan.
37
+ const DEUT = [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881]
38
+ const PROT = [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998]
39
+ const dist = (a: number[], b: number[]) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) * 100
40
+ // worst-case perceived ΔE across deuteranopia and protanopia
41
+ const cvdDelta = (x: string, y: string) => {
42
+ const sim = (M: number[], hex: string) => toOklab(mul(M, hexToRgb(hex).map(lin)))
43
+ return Math.min(
44
+ ...[DEUT, PROT].map((M) => dist(sim(M, x), sim(M, y))),
45
+ )
46
+ }
47
+
48
+ const HEX6 = /^#[0-9A-F]{6}$/
49
+ const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
50
+ const HUES = ['red', 'orange', 'amber', 'green', 'cyan', 'blue', 'magenta'] as const
51
+
52
+ describe('primitiveRamps', () => {
53
+ it('has all seven consolidated hues', () => {
54
+ expect(Object.keys(primitiveRamps).sort()).toEqual([...HUES].sort())
55
+ })
56
+
57
+ it('gives every hue all eleven perceptual steps as uppercase 6-digit hex', () => {
58
+ for (const hue of HUES) {
59
+ const ramp = primitiveRamps[hue] as Record<number, string>
60
+ for (const step of STEPS) {
61
+ expect(ramp[step], `${hue}-${step}`).toMatch(HEX6)
62
+ }
63
+ }
64
+ })
65
+
66
+ it('flows through each pinned anchor color at its step', () => {
67
+ // The step each ramp is pinned to its brand/semantic anchor hex.
68
+ const anchors: Record<string, string> = {
69
+ 'red-600': '#D14343', // status-error
70
+ 'orange-400': '#FF7900', // brand-primary
71
+ 'amber-300': '#F9B415', // status-warning
72
+ 'green-300': '#2ED573', // status-success
73
+ 'cyan-300': '#22D3EE', // vivid cyan identity
74
+ 'blue-500': '#2196F3', // status-info
75
+ 'magenta-700': '#9C0D7A',
76
+ }
77
+ for (const [key, hex] of Object.entries(anchors)) {
78
+ const [hue, step] = key.split('-')
79
+ expect((primitiveRamps as never)[hue][Number(step)]).toBe(hex)
80
+ }
81
+ })
82
+
83
+ it('increases in darkness monotonically (OKLab L descends 50 -> 950)', () => {
84
+ for (const hue of HUES) {
85
+ const ramp = primitiveRamps[hue] as Record<number, string>
86
+ const lightness = STEPS.map((s) => toOklab(hexToRgb(ramp[s]).map(lin))[0])
87
+ for (let i = 1; i < lightness.length; i++) {
88
+ expect(lightness[i], `${hue} ${STEPS[i - 1]}->${STEPS[i]}`).toBeLessThan(lightness[i - 1])
89
+ }
90
+ }
91
+ })
92
+ })
93
+
94
+ describe('categoricalPalette', () => {
95
+ it('has two ordered variants of seven colors', () => {
96
+ expect(categoricalPalette.default).toHaveLength(7)
97
+ expect(categoricalPalette.dark).toHaveLength(7)
98
+ })
99
+
100
+ it('is all uppercase 6-digit hex', () => {
101
+ for (const variant of ['default', 'dark'] as const) {
102
+ for (const hex of categoricalPalette[variant]) {
103
+ expect(hex).toMatch(HEX6)
104
+ }
105
+ }
106
+ })
107
+
108
+ it('holds the deut/protanopia dichromacy floor (deltaE >= 8) through the CVD-safe max', () => {
109
+ // The first CATEGORICAL_CVD_SAFE_MAX colors are legend-free CVD-safe; the 7th is extended.
110
+ for (const variant of ['default', 'dark'] as const) {
111
+ const colors = categoricalPalette[variant].slice(0, CATEGORICAL_CVD_SAFE_MAX)
112
+ let worst = Infinity
113
+ for (let i = 0; i < colors.length; i++) {
114
+ for (let j = i + 1; j < colors.length; j++) {
115
+ worst = Math.min(worst, cvdDelta(colors[i], colors[j]))
116
+ }
117
+ }
118
+ expect(worst, `${variant} min CVD deltaE (first ${CATEGORICAL_CVD_SAFE_MAX})`).toBeGreaterThanOrEqual(8)
119
+ }
120
+ })
121
+
122
+ it('is nested-stable: no color repeats within a variant', () => {
123
+ for (const variant of ['default', 'dark'] as const) {
124
+ expect(new Set(categoricalPalette[variant]).size).toBe(categoricalPalette[variant].length)
125
+ }
126
+ })
127
+
128
+ it('carries readable in-place text: default on black (>=4.5), dark on white (>=3.5)', () => {
129
+ const relLum = (hex: string) => {
130
+ const [r, g, b] = hexToRgb(hex).map(lin)
131
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
132
+ }
133
+ const onBlack = (hex: string) => (relLum(hex) + 0.05) / 0.05
134
+ const onWhite = (hex: string) => 1.05 / (relLum(hex) + 0.05)
135
+ // The in-place text guarantee covers the CVD-safe range; the 7th extended
136
+ // color trades contrast for separation and is meant to be legend-paired.
137
+ for (const hex of categoricalPalette.default.slice(0, CATEGORICAL_CVD_SAFE_MAX)) {
138
+ expect(onBlack(hex), `default ${hex} black-text contrast`).toBeGreaterThanOrEqual(4.5)
139
+ }
140
+ for (const hex of categoricalPalette.dark.slice(0, CATEGORICAL_CVD_SAFE_MAX)) {
141
+ expect(onWhite(hex), `dark ${hex} white-text contrast`).toBeGreaterThanOrEqual(3.5)
142
+ }
143
+ })
144
+ })
145
+
146
+ describe('getCategoricalColor', () => {
147
+ it('returns default-variant colors by index', () => {
148
+ expect(getCategoricalColor(0)).toBe(categoricalPalette.default[0])
149
+ expect(getCategoricalColor(4)).toBe(categoricalPalette.default[4])
150
+ })
151
+
152
+ it('selects the requested variant', () => {
153
+ expect(getCategoricalColor(2, 'dark')).toBe(categoricalPalette.dark[2])
154
+ expect(getCategoricalColor(1, 'dark')).toBe(categoricalPalette.dark[1])
155
+ })
156
+
157
+ it('wraps past the end of the palette', () => {
158
+ expect(getCategoricalColor(7)).toBe(categoricalPalette.default[0])
159
+ expect(getCategoricalColor(9)).toBe(categoricalPalette.default[2])
160
+ })
161
+
162
+ it('clamps negative and fractional indices', () => {
163
+ expect(getCategoricalColor(-3)).toBe(categoricalPalette.default[0])
164
+ expect(getCategoricalColor(2.9)).toBe(categoricalPalette.default[2])
165
+ })
166
+ })
167
+
168
+ const relLum = (hex: string) => {
169
+ const [r, g, b] = hexToRgb(hex).map(lin)
170
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
171
+ }
172
+ const bestContrast = (hex: string) => {
173
+ const l = relLum(hex)
174
+ return Math.max((l + 0.05) / 0.05, 1.05 / (l + 0.05))
175
+ }
176
+
177
+ describe('divergingScale', () => {
178
+ it('is a five-stop diverging scale of uppercase hex', () => {
179
+ expect(divergingScale).toHaveLength(5)
180
+ for (const hex of divergingScale) expect(hex).toMatch(HEX6)
181
+ })
182
+
183
+ it('has a light center and dark extremes (diverging shape)', () => {
184
+ const L = divergingScale.map((h) => toOklab(hexToRgb(h).map(lin))[0])
185
+ expect(L[2], 'optimal is the lightest').toBeGreaterThan(Math.max(L[0], L[4]))
186
+ expect(L[0], 'under is dark').toBeLessThan(L[1])
187
+ expect(L[4], 'over is dark').toBeLessThan(L[3])
188
+ })
189
+
190
+ it('holds a strong CVD floor (deltaE >= 10)', () => {
191
+ let worst = Infinity
192
+ for (let i = 0; i < divergingScale.length; i++) {
193
+ for (let j = i + 1; j < divergingScale.length; j++) {
194
+ worst = Math.min(worst, cvdDelta(divergingScale[i], divergingScale[j]))
195
+ }
196
+ }
197
+ expect(worst).toBeGreaterThanOrEqual(10)
198
+ })
199
+
200
+ it('every cell carries a legible label via best-contrast text (>=4.5)', () => {
201
+ for (const hex of divergingScale) {
202
+ expect(bestContrast(hex), `${hex}`).toBeGreaterThanOrEqual(4.5)
203
+ }
204
+ })
205
+ })
206
+
207
+ describe('sequentialEffort', () => {
208
+ it('is a six-stop ordinal scale of uppercase hex', () => {
209
+ expect(sequentialEffort).toHaveLength(6)
210
+ for (const hex of sequentialEffort) expect(hex).toMatch(HEX6)
211
+ })
212
+
213
+ it('keeps adjacent stops distinguishable', () => {
214
+ for (let i = 0; i < sequentialEffort.length - 1; i++) {
215
+ expect(cvdDelta(sequentialEffort[i], sequentialEffort[i + 1]), `stop ${i}->${i + 1}`).toBeGreaterThanOrEqual(4.5)
216
+ }
217
+ })
218
+
219
+ it('every stop carries a legible label via best-contrast text (>=4.5)', () => {
220
+ for (const hex of sequentialEffort) {
221
+ expect(bestContrast(hex), `${hex}`).toBeGreaterThanOrEqual(4.5)
222
+ }
223
+ })
224
+ })
225
+
226
+ describe('bestTextColor', () => {
227
+ it('picks black on light fills and white on dark fills', () => {
228
+ expect(bestTextColor(primitiveRamps.green[300])).toBe('#000000')
229
+ expect(bestTextColor(primitiveRamps.red[700])).toBe('#FFFFFF')
230
+ expect(bestTextColor(primitiveRamps.blue[700])).toBe('#FFFFFF')
231
+ })
232
+ })
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  export const primitiveColors = {
9
- // Blue scale (primary brand)
9
+ // Blue scale
10
10
  blue: {
11
11
  50: '#EFF6FF',
12
12
  100: '#DBEAFE',
@@ -14,80 +14,38 @@ export const primitiveColors = {
14
14
  300: '#93C5FD',
15
15
  400: '#60A5FA',
16
16
  500: '#3B82F6',
17
- 600: '#5048E5', // Primary main
18
- 700: '#3832A0', // Primary dark
17
+ 600: '#5048E5',
18
+ 700: '#3832A0',
19
19
  800: '#1E40AF',
20
20
  900: '#1E3A8A',
21
21
  },
22
22
 
23
- // Green scale (secondary brand)
24
- green: {
25
- 50: '#ECFDF5',
26
- 100: '#D1FAE5',
27
- 200: '#A7F3D0',
28
- 300: '#6EE7B7',
29
- 400: '#3FC79A', // Secondary light
30
- 500: '#10B981', // Secondary main
31
- 600: '#059669',
32
- 700: '#0B815A', // Secondary dark
33
- 800: '#065F46',
34
- 900: '#064E3B',
35
- },
36
-
37
- // Teal scale (success)
38
- teal: {
39
- 50: '#F0FDFA',
40
- 100: '#CCFBF1',
41
- 200: '#99F6E4',
42
- 300: '#5EEAD4',
43
- 400: '#43C6B7', // Success light
44
- 500: '#14B8A6', // Success main
45
- 600: '#0D9488',
46
- 700: '#0E8074', // Success dark
47
- 800: '#115E59',
48
- 900: '#134E4A',
49
- },
50
-
51
- // Red scale (error)
23
+ // Red scale
52
24
  red: {
53
25
  50: '#FEF2F2',
54
26
  100: '#FEE2E2',
55
27
  200: '#FECACA',
56
28
  300: '#FCA5A5',
57
- 400: '#DA6868', // Error light
29
+ 400: '#DA6868',
58
30
  500: '#EF4444',
59
- 600: '#D14343', // Error main
60
- 700: '#922E2E', // Error dark
31
+ 600: '#D14343',
32
+ 700: '#922E2E',
61
33
  800: '#991B1B',
62
34
  900: '#7F1D1D',
63
35
  },
64
36
 
65
- // Amber scale (warning)
66
- amber: {
67
- 50: '#FFFBEB',
68
- 100: '#FEF3C7',
69
- 200: '#FDE68A',
70
- 300: '#FCD34D',
71
- 400: '#FFBF4C', // Warning light
72
- 500: '#FFB020', // Warning main
73
- 600: '#B27B16', // Warning dark
74
- 700: '#B45309',
75
- 800: '#92400E',
76
- 900: '#78350F',
77
- },
78
-
79
- // Sky/Blue scale (info)
80
- sky: {
81
- 50: '#F0F9FF',
82
- 100: '#E0F2FE',
83
- 200: '#BAE6FD',
84
- 300: '#7DD3FC',
85
- 400: '#64B6F7', // Info light
86
- 500: '#2196F3', // Info main
87
- 600: '#0284C7',
88
- 700: '#0B79D0', // Info dark
89
- 800: '#075985',
90
- 900: '#0C4A6E',
37
+ // Vivid red scale
38
+ redVivid: {
39
+ 50: '#FFECEE',
40
+ 100: '#FFD6DB',
41
+ 200: '#FFB3BC',
42
+ 300: '#FF8593',
43
+ 400: '#FF6070',
44
+ 500: '#FF4757',
45
+ 600: '#E63548',
46
+ 700: '#C42539',
47
+ 800: '#9E1C2C',
48
+ 900: '#7A1520',
91
49
  },
92
50
 
93
51
  // Neutral/Gray scale
@@ -105,19 +63,6 @@ export const primitiveColors = {
105
63
  950: '#030712',
106
64
  },
107
65
 
108
- // Orange scale (accent/brand color)
109
- accent: {
110
- 100: '#FFA860',
111
- 200: '#FF9630',
112
- 300: '#FF8500',
113
- 400: '#FF7900',
114
- 500: '#F56C00',
115
- 600: '#E06D10',
116
- 700: '#D0620C',
117
- 800: '#B75500',
118
- 900: '#A34900',
119
- },
120
-
121
66
  // Charcoal scale (dark backgrounds)
122
67
  charcoal: {
123
68
  0: '#6E6E6E',
@@ -133,25 +78,231 @@ export const primitiveColors = {
133
78
  900: '#101010',
134
79
  },
135
80
 
136
- // Steel scale (cool accent)
137
- steel: {
138
- 100: '#8AA4B8',
139
- 200: '#7894A8',
140
- 300: '#678498',
141
- 400: '#557488',
142
- 500: '#406D87',
143
- 600: '#39617A',
144
- 700: '#32556D',
145
- 800: '#243D53',
146
- 900: '#1D3146',
147
- },
148
-
149
81
  // Pure colors
150
82
  white: '#FFFFFF',
151
83
  black: '#000000',
152
84
  transparent: 'transparent',
153
85
  } as const
154
86
 
87
+ /**
88
+ * Derived tonal ramps (TD-05.09 color foundations)
89
+ *
90
+ * Seven OKLCH-generated hue ramps, 11 perceptual lightness steps each (50 -> 950).
91
+ * Built with hue-torsion + a chroma arc, anchored through existing brand/semantic
92
+ * hexes. `pin` marks the step each ramp flows through its anchor.
93
+ * This is the single source of truth for chromatic hexes — the categorical palette
94
+ * below references these steps rather than duplicating values.
95
+ * See coordination/design-explorations/foundations.
96
+ */
97
+ export const primitiveRamps = {
98
+ red: {
99
+ 50: '#FFF4F4',
100
+ 100: '#FFE3E5',
101
+ 200: '#FFC2C5',
102
+ 300: '#FF9A9D',
103
+ 400: '#F77175',
104
+ 500: '#E05254',
105
+ 600: '#D14343', // pin
106
+ 700: '#A4221C',
107
+ 800: '#7E1002',
108
+ 900: '#5A0900',
109
+ 950: '#3D0400',
110
+ },
111
+ orange: {
112
+ 50: '#FFF5ED',
113
+ 100: '#FFE6D4',
114
+ 200: '#FFC7A2',
115
+ 300: '#FFA063',
116
+ 400: '#FF7900', // pin
117
+ 500: '#DA5F00',
118
+ 600: '#B94A00',
119
+ 700: '#983804',
120
+ 800: '#6F290F',
121
+ 900: '#4E1C0C',
122
+ 950: '#331107',
123
+ },
124
+ amber: {
125
+ 50: '#FFF7DD',
126
+ 100: '#FFEAA9',
127
+ 200: '#FFD352',
128
+ 300: '#F9B415', // pin
129
+ 400: '#E08C00',
130
+ 500: '#C27400',
131
+ 600: '#A45E00',
132
+ 700: '#814D14',
133
+ 800: '#5B3A1A',
134
+ 900: '#442503',
135
+ 950: '#301500',
136
+ },
137
+ green: {
138
+ 50: '#E3FFEE',
139
+ 100: '#B5FFD2',
140
+ 200: '#58F69E',
141
+ 300: '#2ED573', // pin
142
+ 400: '#21C05D',
143
+ 500: '#22A444',
144
+ 600: '#298732',
145
+ 700: '#2B6B25',
146
+ 800: '#264D1C',
147
+ 900: '#1B3515',
148
+ 950: '#11220D',
149
+ },
150
+ cyan: {
151
+ 50: '#E6FBFF',
152
+ 100: '#C2F6FF',
153
+ 200: '#62EAFF',
154
+ 300: '#22D3EE', // pin
155
+ 400: '#01B5D1',
156
+ 500: '#2697B7',
157
+ 600: '#307B9B',
158
+ 700: '#2A617F',
159
+ 800: '#22465F',
160
+ 900: '#0B3149',
161
+ 950: '#001F36',
162
+ },
163
+ blue: {
164
+ 50: '#EFF8FF',
165
+ 100: '#D9EFFF',
166
+ 200: '#ACDBFF',
167
+ 300: '#78C2FF',
168
+ 400: '#3CA8FF',
169
+ 500: '#2196F3', // pin
170
+ 600: '#1072CB',
171
+ 700: '#135AA8',
172
+ 800: '#14407E',
173
+ 900: '#112C5A',
174
+ 950: '#091B3C',
175
+ },
176
+ magenta: {
177
+ 50: '#FFF3F9',
178
+ 100: '#FFE2F1',
179
+ 200: '#FFBDE2',
180
+ 300: '#FF8FD5',
181
+ 400: '#ED69C3',
182
+ 500: '#D548AF',
183
+ 600: '#BA2996',
184
+ 700: '#9C0D7A', // pin
185
+ 800: '#77005A',
186
+ 900: '#56003F',
187
+ 950: '#3B0029',
188
+ },
189
+ } as const
190
+
191
+ /**
192
+ * Categorical (qualitative) palette — an ordered, CVD-safe series expressed as
193
+ * references into {@link primitiveRamps} (one source of truth; the palette moves
194
+ * with the ramps).
195
+ *
196
+ * Design: a single canonical hue order (blue, magenta, red, orange, green, cyan,
197
+ * amber), with steps chosen vivid-midtone-first and fanned lighter/darker as the
198
+ * series grows to preserve separation. The sequence is nested-stable — a chart
199
+ * with N series uses the first N colors, and the series index is stable as N
200
+ * changes. Colorblind-safe worst-case deuteranopia/protanopia floor 8 holds
201
+ * through {@link CATEGORICAL_CVD_SAFE_MAX} colors; the 7th is an extended color
202
+ * that should be paired with a legend or other secondary encoding.
203
+ *
204
+ * Two variants: `default` (vivid, sits on neutral/light surfaces, legible under
205
+ * black text) and `dark` (deeper shades legible under white text on a fill). A
206
+ * separate "light" variant was dropped — the vivid `default` picks already clear
207
+ * black-text contrast, so it doubles as the light-context palette.
208
+ */
209
+ export const categoricalPalette = {
210
+ default: [
211
+ primitiveRamps.blue[500],
212
+ primitiveRamps.magenta[500],
213
+ primitiveRamps.red[500],
214
+ primitiveRamps.orange[400],
215
+ primitiveRamps.green[300],
216
+ primitiveRamps.cyan[300],
217
+ primitiveRamps.amber[600], // extended (7th) — pair with a legend
218
+ ],
219
+ dark: [
220
+ primitiveRamps.blue[600],
221
+ primitiveRamps.magenta[600],
222
+ primitiveRamps.red[500],
223
+ primitiveRamps.orange[600],
224
+ primitiveRamps.green[800],
225
+ primitiveRamps.cyan[800],
226
+ primitiveRamps.amber[500], // extended (7th) — pair with a legend
227
+ ],
228
+ } as const
229
+
230
+ /** Largest series count that holds the CVD floor without a legend. */
231
+ export const CATEGORICAL_CVD_SAFE_MAX = 6
232
+
233
+ /**
234
+ * Categorical palette variant.
235
+ * - `default` : vivid; neutral/light surfaces, legible under black text
236
+ * - `dark` : deeper shades, legible under white text on a filled swatch
237
+ */
238
+ export type CategoricalVariant = keyof typeof categoricalPalette
239
+
240
+ /**
241
+ * Get a color from the ordered categorical palette by series index.
242
+ * The palette is nested-stable, so the color for a given index does not change
243
+ * with the total series count; `index` simply wraps past the end.
244
+ *
245
+ * @param index - 0-based series index (wraps past the end)
246
+ * @param variant - `default` (black-text / light surfaces) or `dark` (white text)
247
+ * @returns The hex color string
248
+ */
249
+ export function getCategoricalColor(index: number, variant: CategoricalVariant = 'default'): string {
250
+ const palette = categoricalPalette[variant]
251
+ index = Math.max(0, Math.floor(index))
252
+ return palette[index % palette.length]
253
+ }
254
+
255
+ /**
256
+ * Diverging status scale (BodyMap training-status: under → optimal → over) as
257
+ * references into {@link primitiveRamps}. A true diverging shape — `optimal` is
258
+ * the lightest center, the extremes go darker — so the signal reads in lightness
259
+ * (colorblind-robust; worst-case deut/protanopia min ΔE ~10.9) and the direction
260
+ * reads in the blue↔red axis (the CVD-safe hue pair).
261
+ *
262
+ * Tuned so every stop is dark enough for the fill yet light enough that BLACK
263
+ * text clears 4.5:1 on all five — no per-cell white/black flipping. The value
264
+ * valley is symmetric: the two mid-arms (maintenance / approaching) sit at equal
265
+ * lightness (both ~0.53 relative luminance), the ends darker.
266
+ */
267
+ export const divergingScale = [
268
+ primitiveRamps.blue[500], // under
269
+ primitiveRamps.cyan[300], // maintenance
270
+ primitiveRamps.green[200], // optimal (light center)
271
+ primitiveRamps.amber[300], // approaching
272
+ primitiveRamps.red[600], // over
273
+ ] as const
274
+
275
+ /**
276
+ * Sequential "effort" scale (low → high intensity: green → yellow → orange → red)
277
+ * as references into {@link primitiveRamps}. A 6-stop ordinal palette; the
278
+ * velocity/RPE strip sub-samples it. Every adjacent pair clears the CVD floor
279
+ * (min deut/protan ΔE ≥ 4.5), and within that the hue walk is kept smooth —
280
+ * the amber-200 → amber-300 → orange-400 run steps the yellow→orange transition
281
+ * gradually rather than lurching. Order encodes magnitude, so it tolerates the
282
+ * green↔red CVD pair. Stops 0–3 take black text; the dark reds take white.
283
+ */
284
+ export const sequentialEffort = [
285
+ primitiveRamps.green[300],
286
+ primitiveRamps.amber[200],
287
+ primitiveRamps.amber[300],
288
+ primitiveRamps.orange[400],
289
+ primitiveRamps.red[600],
290
+ primitiveRamps.red[700],
291
+ ] as const
292
+
293
+ /**
294
+ * Best-contrast text color (black or white) for a solid fill — for labels sitting
295
+ * on categorical/diverging/effort swatches. Uses the WCAG relative-luminance ratio.
296
+ */
297
+ export function bestTextColor(hex: string): '#000000' | '#FFFFFF' {
298
+ const [r, g, b] = [0, 2, 4].map((i) => {
299
+ const c = parseInt(hex.replace('#', '').slice(i, i + 2), 16) / 255
300
+ return c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92
301
+ })
302
+ const l = 0.2126 * r + 0.7152 * g + 0.0722 * b
303
+ return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) ? '#000000' : '#FFFFFF'
304
+ }
305
+
155
306
  /**
156
307
  * Discrete rainbow palette for data visualization
157
308
  * Based on Paul Tol's color schemes: https://personal.sron.nl/~pault/#fig:scheme_rainbow_discrete