@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,150 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { renderHook, act } from '@testing-library/react'
3
+ import { useTimer, formatDuration } from './useTimer'
4
+
5
+ describe('formatDuration', () => {
6
+ it('formats zero as 0:00', () => {
7
+ expect(formatDuration(0)).toBe('0:00')
8
+ })
9
+
10
+ it('pads seconds with a leading zero', () => {
11
+ expect(formatDuration(65000)).toBe('1:05')
12
+ })
13
+
14
+ it('rounds partial seconds up', () => {
15
+ expect(formatDuration(4500)).toBe('0:05')
16
+ })
17
+
18
+ it('clamps negative input to 0:00', () => {
19
+ expect(formatDuration(-1000)).toBe('0:00')
20
+ })
21
+
22
+ it('formats multi-minute durations', () => {
23
+ expect(formatDuration(150000)).toBe('2:30')
24
+ })
25
+ })
26
+
27
+ describe('useTimer (controlled, down)', () => {
28
+ it('derives remaining, progress, and label from elapsedMs', () => {
29
+ const { result } = renderHook(() =>
30
+ useTimer({ mode: 'down', durationMs: 100000, elapsedMs: 30000 })
31
+ )
32
+ expect(result.current.remainingMs).toBe(70000)
33
+ expect(result.current.progress).toBeCloseTo(0.3)
34
+ expect(result.current.label).toBe('1:10')
35
+ expect(result.current.done).toBe(false)
36
+ })
37
+
38
+ it('clamps remaining to 0 and progress to 1 when elapsed exceeds duration', () => {
39
+ const { result } = renderHook(() =>
40
+ useTimer({ mode: 'down', durationMs: 100000, elapsedMs: 200000 })
41
+ )
42
+ expect(result.current.remainingMs).toBe(0)
43
+ expect(result.current.progress).toBe(1)
44
+ expect(result.current.label).toBe('0:00')
45
+ expect(result.current.done).toBe(true)
46
+ })
47
+
48
+ it('reports progress 1 and done for a zero-duration countdown', () => {
49
+ const { result } = renderHook(() => useTimer({ mode: 'down', durationMs: 0, elapsedMs: 0 }))
50
+ expect(result.current.progress).toBe(1)
51
+ expect(result.current.done).toBe(true)
52
+ expect(result.current.label).toBe('0:00')
53
+ })
54
+ })
55
+
56
+ describe('useTimer (controlled, up)', () => {
57
+ it('counts elapsed upward and computes progress against duration', () => {
58
+ const { result } = renderHook(() =>
59
+ useTimer({ mode: 'up', durationMs: 120000, elapsedMs: 30000 })
60
+ )
61
+ expect(result.current.elapsedMs).toBe(30000)
62
+ expect(result.current.progress).toBeCloseTo(0.25)
63
+ expect(result.current.label).toBe('0:30')
64
+ })
65
+
66
+ it('reports progress 0 and never done without a duration', () => {
67
+ const { result } = renderHook(() => useTimer({ mode: 'up', elapsedMs: 45000 }))
68
+ expect(result.current.progress).toBe(0)
69
+ expect(result.current.done).toBe(false)
70
+ expect(result.current.label).toBe('0:45')
71
+ })
72
+ })
73
+
74
+ describe('useTimer (self-ticking)', () => {
75
+ afterEach(() => {
76
+ vi.useRealTimers()
77
+ })
78
+
79
+ it('advances elapsed on each interval while running', () => {
80
+ vi.useFakeTimers()
81
+ const { result } = renderHook(() =>
82
+ useTimer({ mode: 'up', autoTick: true, running: true, tickMs: 1000 })
83
+ )
84
+ expect(result.current.elapsedMs).toBe(0)
85
+ act(() => {
86
+ vi.advanceTimersByTime(3000)
87
+ })
88
+ expect(result.current.elapsedMs).toBe(3000)
89
+ })
90
+
91
+ it('counts a down-timer toward 0 and reports done', () => {
92
+ vi.useFakeTimers()
93
+ const { result } = renderHook(() =>
94
+ useTimer({ mode: 'down', durationMs: 3000, autoTick: true, running: true })
95
+ )
96
+ expect(result.current.label).toBe('0:03')
97
+ act(() => {
98
+ vi.advanceTimersByTime(3000)
99
+ })
100
+ expect(result.current.remainingMs).toBe(0)
101
+ expect(result.current.label).toBe('0:00')
102
+ expect(result.current.done).toBe(true)
103
+ })
104
+
105
+ it('does not tick when not running', () => {
106
+ vi.useFakeTimers()
107
+ const { result } = renderHook(() => useTimer({ mode: 'up', autoTick: true, running: false }))
108
+ act(() => {
109
+ vi.advanceTimersByTime(5000)
110
+ })
111
+ expect(result.current.elapsedMs).toBe(0)
112
+ })
113
+
114
+ it('does not tick when a controlled elapsedMs is supplied', () => {
115
+ vi.useFakeTimers()
116
+ const { result } = renderHook(() =>
117
+ useTimer({ mode: 'up', autoTick: true, running: true, elapsedMs: 10000 })
118
+ )
119
+ act(() => {
120
+ vi.advanceTimersByTime(5000)
121
+ })
122
+ expect(result.current.elapsedMs).toBe(10000)
123
+ })
124
+
125
+ it('clears the interval on unmount', () => {
126
+ vi.useFakeTimers()
127
+ const clearSpy = vi.spyOn(global, 'clearInterval')
128
+ const { unmount } = renderHook(() => useTimer({ mode: 'up', autoTick: true, running: true }))
129
+ unmount()
130
+ expect(clearSpy).toHaveBeenCalled()
131
+ clearSpy.mockRestore()
132
+ })
133
+
134
+ it('stops ticking after running flips to false', () => {
135
+ vi.useFakeTimers()
136
+ const { result, rerender } = renderHook(
137
+ ({ running }) => useTimer({ mode: 'up', autoTick: true, running }),
138
+ { initialProps: { running: true } }
139
+ )
140
+ act(() => {
141
+ vi.advanceTimersByTime(2000)
142
+ })
143
+ expect(result.current.elapsedMs).toBe(2000)
144
+ rerender({ running: false })
145
+ act(() => {
146
+ vi.advanceTimersByTime(5000)
147
+ })
148
+ expect(result.current.elapsedMs).toBe(2000)
149
+ })
150
+ })
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ export type TimerMode = 'up' | 'down'
4
+
5
+ export interface UseTimerOptions {
6
+ /** 'up' counts elapsed upward; 'down' counts remaining toward 0. Default 'up'. */
7
+ mode?: TimerMode
8
+ /** Total duration in ms. Required for 'down' mode and for progress in 'up' mode. */
9
+ durationMs?: number
10
+ /** Controlled elapsed time in ms. When provided, the hook derives everything from it and never ticks. */
11
+ elapsedMs?: number
12
+ /** Self-tick only advances while running. */
13
+ running?: boolean
14
+ /** Enable the internal interval. Ignored when `elapsedMs` is controlled. */
15
+ autoTick?: boolean
16
+ /** Interval step in ms. Default 1000. */
17
+ tickMs?: number
18
+ }
19
+
20
+ export interface TimerState {
21
+ elapsedMs: number
22
+ remainingMs: number
23
+ /** 0..1, clamped. */
24
+ progress: number
25
+ /** mm:ss for the active direction (remaining when 'down', elapsed when 'up'). */
26
+ label: string
27
+ /** True once a down-timer reaches 0 (or an up-timer passes its duration). */
28
+ done: boolean
29
+ }
30
+
31
+ /** Format a millisecond duration as mm:ss, rounding partial seconds up. */
32
+ export function formatDuration(ms: number): string {
33
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000))
34
+ const minutes = Math.floor(totalSeconds / 60)
35
+ const seconds = totalSeconds % 60
36
+ return `${minutes}:${String(seconds).padStart(2, '0')}`
37
+ }
38
+
39
+ export function useTimer(options: UseTimerOptions = {}): TimerState {
40
+ const {
41
+ mode = 'up',
42
+ durationMs,
43
+ elapsedMs: controlledElapsedMs,
44
+ running = false,
45
+ autoTick = false,
46
+ tickMs = 1000,
47
+ } = options
48
+
49
+ const isControlled = controlledElapsedMs != null
50
+ const [internalElapsedMs, setInternalElapsedMs] = useState(0)
51
+
52
+ useEffect(() => {
53
+ if (isControlled || !autoTick || !running) return
54
+ const id = setInterval(() => {
55
+ setInternalElapsedMs((prev) => prev + tickMs)
56
+ }, tickMs)
57
+ return () => clearInterval(id)
58
+ }, [isControlled, autoTick, running, tickMs])
59
+
60
+ const elapsedMs = isControlled ? controlledElapsedMs : internalElapsedMs
61
+
62
+ const hasDuration = durationMs != null && durationMs > 0
63
+ const remainingMs = durationMs != null ? Math.max(0, durationMs - elapsedMs) : 0
64
+
65
+ let progress: number
66
+ if (hasDuration) {
67
+ progress = Math.min(1, Math.max(0, elapsedMs / durationMs))
68
+ } else {
69
+ // A zero/undefined duration is trivially complete for a countdown, empty for a stopwatch.
70
+ progress = mode === 'down' ? 1 : 0
71
+ }
72
+
73
+ const label = formatDuration(mode === 'down' ? remainingMs : elapsedMs)
74
+ const done = durationMs != null && elapsedMs >= durationMs
75
+
76
+ return { elapsedMs, remainingMs, progress, label, done }
77
+ }
package/src/index.ts CHANGED
@@ -9,3 +9,6 @@ export * from './theme/barrel'
9
9
 
10
10
  // Utilities
11
11
  export * from './utils'
12
+
13
+ // Hooks
14
+ export * from './hooks'
package/src/pages.ts CHANGED
@@ -15,7 +15,7 @@ export {
15
15
  countCompletedSets,
16
16
  deriveWorkoutProgress,
17
17
  findActiveExercise,
18
- statusToCardState,
18
+ isUpcomingExercise,
19
19
  toActiveCardProps,
20
20
  groupExercises,
21
21
  type ActiveWorkoutPageProps,
@@ -438,7 +438,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode
438
438
  // ---------------------------------------------------------------------------
439
439
 
440
440
  const meta: Meta = {
441
- title: 'Theme/Preset Showcase',
441
+ title: 'Lab/Recipes/Theme Preset Showcase',
442
442
  parameters: {
443
443
  layout: 'fullscreen',
444
444
  },
@@ -66,7 +66,7 @@ function PresetShowcase({ preset }: { preset: typeof audiobookPreset }) {
66
66
  }
67
67
 
68
68
  const meta: Meta = {
69
- title: 'Theme/Presets',
69
+ title: 'Foundations/Theme Presets',
70
70
  parameters: {
71
71
  layout: 'fullscreen',
72
72
  },
@@ -0,0 +1,323 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View, Text } from 'react-native'
4
+ import {
5
+ primitiveColors,
6
+ primitiveRamps,
7
+ categoricalPalette,
8
+ getCategoricalColor,
9
+ CATEGORICAL_CVD_SAFE_MAX,
10
+ divergingScale,
11
+ sequentialEffort,
12
+ bestTextColor,
13
+ } from './tokens/primitives'
14
+
15
+ const meta: Meta = {
16
+ title: 'Foundations/Color/System',
17
+ tags: ['autodocs'],
18
+ }
19
+ export default meta
20
+
21
+ // ---- color math (self-contained; drives the accessibility panel) ----
22
+ const hexToRgb = (hex: string): [number, number, number] => {
23
+ const h = hex.replace('#', '')
24
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
25
+ }
26
+ const toHex = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0')
27
+ const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
28
+ const gamma = (c: number) => (c >= 0.0031308 ? 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055 : 12.92 * c)
29
+ const relLum = (hex: string) => {
30
+ const [r, g, b] = hexToRgb(hex).map(lin)
31
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
32
+ }
33
+ const contrast = (a: string, b: string) => {
34
+ const [l1, l2] = [relLum(a), relLum(b)].sort((x, y) => y - x)
35
+ return (l1 + 0.05) / (l2 + 0.05)
36
+ }
37
+ const bestText = (hex: string) => (contrast(hex, '#000000') >= contrast(hex, '#FFFFFF') ? '#000000' : '#FFFFFF')
38
+ // Machado-2009 dichromacy simulation (severity 1.0)
39
+ const CVD = {
40
+ deuteranopia: [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881],
41
+ protanopia: [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998],
42
+ } as const
43
+ const simulate = (M: readonly number[], hex: string) => {
44
+ const [r, g, b] = hexToRgb(hex).map(lin)
45
+ const out = [
46
+ M[0] * r + M[1] * g + M[2] * b,
47
+ M[3] * r + M[4] * g + M[5] * b,
48
+ M[6] * r + M[7] * g + M[8] * b,
49
+ ].map(gamma)
50
+ return '#' + out.map(toHex).join('')
51
+ }
52
+
53
+ const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
54
+
55
+ function SectionTitle({ children }: { children: string }) {
56
+ return <Text className="text-lg font-bold text-text-primary mb-3 mt-2">{children}</Text>
57
+ }
58
+
59
+ function Ramp({ name, ramp }: { name: string; ramp: Record<number, string> }) {
60
+ return (
61
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 6 }}>
62
+ <Text className="text-text-secondary text-xs" style={{ width: 150 }}>
63
+ {name}
64
+ </Text>
65
+ <View style={{ flexDirection: 'row', flex: 1, borderRadius: 6, overflow: 'hidden', borderWidth: 1, borderColor: '#2C2C2C' }}>
66
+ {STEPS.map((s) => (
67
+ <View key={s} style={{ flex: 1, height: 30, backgroundColor: ramp[s] }} />
68
+ ))}
69
+ </View>
70
+ </View>
71
+ )
72
+ }
73
+
74
+ function Chip({ hex, label, sub }: { hex: string; label?: string; sub?: string }) {
75
+ return (
76
+ <View style={{ alignItems: 'center' }}>
77
+ <View
78
+ style={{
79
+ width: 58,
80
+ height: 44,
81
+ borderRadius: 8,
82
+ backgroundColor: hex,
83
+ borderWidth: 1,
84
+ borderColor: '#2C2C2C',
85
+ alignItems: 'center',
86
+ justifyContent: 'center',
87
+ }}
88
+ >
89
+ {label ? <Text style={{ color: bestText(hex), fontSize: 11, fontWeight: '700' }}>{label}</Text> : null}
90
+ </View>
91
+ {sub ? <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>{sub}</Text> : null}
92
+ </View>
93
+ )
94
+ }
95
+
96
+ const RAMP_NAMES: Array<[keyof typeof primitiveRamps, string]> = [
97
+ ['red', 'Red'],
98
+ ['orange', 'Orange'],
99
+ ['amber', 'Amber'],
100
+ ['green', 'Green'],
101
+ ['cyan', 'Cyan'],
102
+ ['blue', 'Blue'],
103
+ ['magenta', 'Magenta'],
104
+ ]
105
+
106
+ const SEM = {
107
+ success: primitiveRamps.green[300],
108
+ warning: primitiveRamps.amber[300],
109
+ error: primitiveRamps.red[600],
110
+ info: primitiveRamps.blue[500],
111
+ deload: primitiveRamps.magenta[600],
112
+ brand: primitiveRamps.orange[400],
113
+ brandDark: primitiveRamps.orange[600],
114
+ brandSecondary: primitiveRamps.cyan[600],
115
+ spinner: primitiveRamps.cyan[600],
116
+ neutral: primitiveColors.neutral[500],
117
+ } as const
118
+
119
+ // BodyMap training-status diverging scale (from the divergingScale token).
120
+ const D3_STATUS = divergingScale
121
+
122
+ export const Overview: StoryObj = {
123
+ render: () => (
124
+ <View style={{ padding: 24, gap: 6 }}>
125
+ <Text className="text-2xl font-bold text-text-primary mb-1">Color System</Text>
126
+ <Text className="text-text-secondary mb-4">
127
+ Seven OKLCH tonal ramps and an ordered, colorblind-safe categorical palette. Ramps are the single
128
+ source of truth; the categorical references ramp steps.
129
+ </Text>
130
+
131
+ <SectionTitle>Tonal ramps</SectionTitle>
132
+ {RAMP_NAMES.map(([key, name]) => (
133
+ <Ramp key={key} name={name} ramp={primitiveRamps[key]} />
134
+ ))}
135
+
136
+ <SectionTitle>Semantic tokens</SectionTitle>
137
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 18 }}>
138
+ {Object.entries(SEM).map(([label, hex]) => (
139
+ <View key={label} style={{ alignItems: 'center' }}>
140
+ <Chip hex={hex} />
141
+ <Text className="text-text-secondary" style={{ fontSize: 9, marginTop: 3 }}>
142
+ {label}
143
+ </Text>
144
+ </View>
145
+ ))}
146
+ </View>
147
+
148
+ <SectionTitle>Categorical — ordered, nested, CVD-safe to 6</SectionTitle>
149
+ {(['default', 'dark'] as const).map((variant) => (
150
+ <View key={variant} style={{ marginBottom: 10 }}>
151
+ <Text className="text-text-secondary text-xs mb-1" style={{ textTransform: 'capitalize' }}>
152
+ {variant}
153
+ </Text>
154
+ <View style={{ flexDirection: 'row', gap: 6 }}>
155
+ {categoricalPalette[variant].map((hex, i) => (
156
+ <Chip key={i} hex={hex} label={`${i + 1}`} sub={i < CATEGORICAL_CVD_SAFE_MAX ? undefined : 'ext'} />
157
+ ))}
158
+ </View>
159
+ </View>
160
+ ))}
161
+
162
+ <SectionTitle>Data-viz scales</SectionTitle>
163
+ <Text className="text-text-secondary text-xs mb-2">
164
+ Diverging (BodyMap training-status) — light-centered so it reads in lightness and stays
165
+ colorblind-stable; direction is the blue↔red axis. Labels use per-cell best-contrast text.
166
+ </Text>
167
+ <View style={{ flexDirection: 'row', gap: 6, marginBottom: 12 }}>
168
+ {['under', 'maint', 'optimal', 'approach', 'over'].map((l, i) => (
169
+ <View key={l} style={{ flex: 1, height: 40, borderRadius: 6, backgroundColor: divergingScale[i], alignItems: 'center', justifyContent: 'center' }}>
170
+ <Text style={{ color: bestTextColor(divergingScale[i]), fontSize: 10, fontWeight: '700' }}>{l}</Text>
171
+ </View>
172
+ ))}
173
+ </View>
174
+ <Text className="text-text-secondary text-xs mb-2">
175
+ Sequential effort (low → high) — an ordinal green→yellow→orange→red scale that IntensityBar uses
176
+ in full and VelocityStrip/RPE sub-samples.
177
+ </Text>
178
+ <View style={{ flexDirection: 'row', gap: 4, marginBottom: 4 }}>
179
+ {sequentialEffort.map((hex, i) => (
180
+ <View key={i} style={{ flex: 1, height: 34, borderRadius: 5, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
181
+ <Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>{i + 1}</Text>
182
+ </View>
183
+ ))}
184
+ </View>
185
+
186
+ <SectionTitle>Component examples — correct color roles</SectionTitle>
187
+ <View style={{ flexDirection: 'row', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
188
+ {/* BodyMap muscle heatmap — training-status diverging (D3) */}
189
+ <Demo label="BodyMap · training status">
190
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 132, gap: 4 }}>
191
+ {[
192
+ ['Chest', 4], ['Quads', 2], ['Back', 3],
193
+ ['Delts', 0], ['Biceps', 4], ['Calves', 1],
194
+ ].map(([m, idx]) => (
195
+ <View key={m as string} style={{ width: 40, backgroundColor: D3_STATUS[idx as number], borderRadius: 5, paddingVertical: 6 }}>
196
+ <Text style={{ color: bestTextColor(D3_STATUS[idx as number]), fontSize: 8, fontWeight: '700', textAlign: 'center' }}>{m as string}</Text>
197
+ </View>
198
+ ))}
199
+ </View>
200
+ </Demo>
201
+ {/* StatusDot — semantic */}
202
+ <Demo label="StatusDot · semantic">
203
+ <View style={{ gap: 6 }}>
204
+ {[['ok', SEM.success], ['warn', SEM.warning], ['err', SEM.error], ['neutral', SEM.neutral]].map(([l, c]) => (
205
+ <View key={l as string} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
206
+ <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: c as string }} />
207
+ <Text className="text-text-secondary" style={{ fontSize: 11 }}>{l as string}</Text>
208
+ </View>
209
+ ))}
210
+ </View>
211
+ </Demo>
212
+ {/* WorkoutPill — status */}
213
+ <Demo label="WorkoutPill">
214
+ <View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', maxWidth: 220 }}>
215
+ {[['Done', SEM.success], ['Now', SEM.brand], ['Next', SEM.neutral], ['Deload', SEM.deload]].map(([t, c]) => (
216
+ <View key={t as string} style={{ borderRadius: 99, borderWidth: 1, borderColor: c as string, paddingHorizontal: 10, paddingVertical: 3 }}>
217
+ <Text style={{ color: c as string, fontSize: 11, fontWeight: '700' }}>{t as string}</Text>
218
+ </View>
219
+ ))}
220
+ </View>
221
+ </Demo>
222
+ {/* StrengthTrend legend — series → semantic */}
223
+ <Demo label="StrengthTrend · series">
224
+ <View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap' }}>
225
+ {[['1RM', SEM.brand], ['vel', SEM.success], ['miss', SEM.error], ['RPE', SEM.warning]].map(([l, c]) => (
226
+ <View key={l as string} style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
227
+ <View style={{ width: 14, height: 3, backgroundColor: c as string, borderRadius: 2 }} />
228
+ <Text className="text-text-secondary" style={{ fontSize: 10 }}>{l as string}</Text>
229
+ </View>
230
+ ))}
231
+ </View>
232
+ </Demo>
233
+ {/* Treemap — ordered categorical */}
234
+ <Demo label="Treemap · categorical">
235
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 140, gap: 2 }}>
236
+ {[46, 30, 30, 24, 24, 24, 20].map((h, i) => (
237
+ <View key={i} style={{ height: h, flexGrow: 1, flexBasis: h > 40 ? 60 : 40, backgroundColor: getCategoricalColor(i), borderRadius: 3 }} />
238
+ ))}
239
+ </View>
240
+ </Demo>
241
+ {/* Spinner */}
242
+ <Demo label="Spinner">
243
+ <View style={{ width: 24, height: 24, borderRadius: 12, borderWidth: 3, borderColor: '#2C2C2C', borderTopColor: SEM.spinner }} />
244
+ </Demo>
245
+ {/* IntensityBar — full effort scale */}
246
+ <Demo label="IntensityBar · effort">
247
+ <View style={{ flexDirection: 'row', gap: 2, width: 132 }}>
248
+ {sequentialEffort.map((hex, i) => (
249
+ <View key={i} style={{ flex: 1, height: 20, backgroundColor: hex, borderRadius: 2 }} />
250
+ ))}
251
+ </View>
252
+ </Demo>
253
+ {/* VelocityStrip / RPE — effort sub-sample */}
254
+ <Demo label="VelocityStrip · RPE">
255
+ <View style={{ flexDirection: 'row', gap: 3 }}>
256
+ {[sequentialEffort[0], sequentialEffort[2], sequentialEffort[3], sequentialEffort[5]].map((hex, i) => (
257
+ <View key={i} style={{ width: 30, height: 26, borderRadius: 4, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
258
+ <Text style={{ color: bestTextColor(hex), fontSize: 9, fontWeight: '700' }}>{['S', 'M', 'F', 'X'][i]}</Text>
259
+ </View>
260
+ ))}
261
+ </View>
262
+ </Demo>
263
+ </View>
264
+ </View>
265
+ ),
266
+ }
267
+
268
+ function Demo({ label, children }: { label: string; children: ReactNode }) {
269
+ return (
270
+ <View style={{ padding: 10, backgroundColor: '#1A1A1A', borderRadius: 8 }}>
271
+ <Text className="text-text-secondary" style={{ fontSize: 9, marginBottom: 6 }}>{label}</Text>
272
+ {children}
273
+ </View>
274
+ )
275
+ }
276
+
277
+ export const Accessibility: StoryObj = {
278
+ render: () => (
279
+ <View style={{ padding: 24 }}>
280
+ <Text className="text-2xl font-bold text-text-primary mb-1">Categorical Accessibility</Text>
281
+ <Text className="text-text-secondary mb-5">
282
+ In-place text contrast and colorblind simulation for the categorical series. Converging strips across
283
+ two swatches signal a collision for that viewer; all pairs hold ΔE ≥ 8 through the first {CATEGORICAL_CVD_SAFE_MAX}.
284
+ </Text>
285
+
286
+ {(['default', 'dark'] as const).map((variant) => {
287
+ const colors = categoricalPalette[variant]
288
+ const textColor = variant === 'dark' ? '#FFFFFF' : '#000000'
289
+ return (
290
+ <View key={variant} style={{ marginBottom: 22 }}>
291
+ <Text className="text-lg font-bold text-text-primary mb-2" style={{ textTransform: 'capitalize' }}>
292
+ {variant}
293
+ </Text>
294
+ {/* swatches with in-place text + contrast ratio */}
295
+ <View style={{ flexDirection: 'row', gap: 6, marginBottom: 6 }}>
296
+ {colors.map((hex, i) => (
297
+ <View key={i} style={{ alignItems: 'center' }}>
298
+ <View style={{ width: 56, height: 40, borderRadius: 6, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
299
+ <Text style={{ color: textColor, fontSize: 11, fontWeight: '700' }}>Aa</Text>
300
+ </View>
301
+ <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>
302
+ {contrast(hex, textColor).toFixed(1)}:1
303
+ </Text>
304
+ </View>
305
+ ))}
306
+ </View>
307
+ {/* CVD simulation strips */}
308
+ {(['deuteranopia', 'protanopia'] as const).map((mode) => (
309
+ <View key={mode} style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
310
+ <Text className="text-text-secondary" style={{ fontSize: 9, width: 96 }}>{mode}</Text>
311
+ <View style={{ flexDirection: 'row', gap: 6 }}>
312
+ {colors.map((hex, i) => (
313
+ <View key={i} style={{ width: 56, height: 14, borderRadius: 3, backgroundColor: simulate(CVD[mode], hex) }} />
314
+ ))}
315
+ </View>
316
+ </View>
317
+ ))}
318
+ </View>
319
+ )
320
+ })}
321
+ </View>
322
+ ),
323
+ }