@titan-design/react-ui 0.6.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 (248) hide show
  1. package/dist/bodymap.js +12 -7
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +12 -7
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +855 -124
  6. package/dist/index.d.ts +855 -124
  7. package/dist/index.js +2276 -801
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2230 -802
  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 +1255 -798
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1255 -798
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +16 -82
  20. package/dist/theme/index.d.ts +16 -82
  21. package/dist/theme/index.js +13 -115
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +13 -115
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +14 -6
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +14 -6
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +1 -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 +1 -1
  34. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
  36. package/src/components/custom/Metric/Metric.stories.tsx +1 -1
  37. package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
  38. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  39. package/src/components/custom/Table/Table.stories.tsx +1 -1
  40. package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
  41. package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
  42. package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
  43. package/src/components/custom/TimerReadout/index.ts +1 -0
  44. package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
  45. package/src/components/custom/Typography/Typography.stories.tsx +1 -1
  46. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
  47. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
  48. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +9 -15
  49. package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
  50. package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
  51. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
  52. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
  53. package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
  54. package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
  55. package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
  56. package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
  57. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
  58. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
  59. package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
  60. package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
  61. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
  62. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
  63. package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -5
  64. package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
  65. package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
  66. package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
  67. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
  68. package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
  69. package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
  70. package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
  71. package/src/components/custom/Workout/FatigueMeter.test.tsx +73 -0
  72. package/src/components/custom/Workout/FatigueMeter.tsx +82 -0
  73. package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
  74. package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
  75. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
  76. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
  77. package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
  78. package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
  79. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
  80. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
  81. package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
  82. package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
  83. package/src/components/custom/Workout/MetricTiles.tsx +48 -0
  84. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
  85. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
  86. package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
  87. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
  88. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
  89. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -6
  90. package/src/components/custom/Workout/README.md +146 -4
  91. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
  92. package/src/components/custom/Workout/RestTimer.stories.tsx +1 -1
  93. package/src/components/custom/Workout/RestTimer.tsx +15 -10
  94. package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
  95. package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
  96. package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
  97. package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
  98. package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
  99. package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
  100. package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
  101. package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
  102. package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
  103. package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
  104. package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
  105. package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
  106. package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
  107. package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
  108. package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
  109. package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
  110. package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
  111. package/src/components/custom/Workout/SessionHeader.tsx +152 -0
  112. package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
  113. package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
  114. package/src/components/custom/Workout/SessionRail.tsx +126 -0
  115. package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
  116. package/src/components/custom/Workout/SetBar.test.tsx +123 -0
  117. package/src/components/custom/Workout/SetBar.tsx +171 -0
  118. package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
  119. package/src/components/custom/Workout/SetRow.test.tsx +94 -166
  120. package/src/components/custom/Workout/SetRow.tsx +169 -232
  121. package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
  122. package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
  123. package/src/components/custom/Workout/SetStrip.tsx +71 -0
  124. package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
  125. package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
  126. package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
  127. package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
  128. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
  129. package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
  130. package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
  131. package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
  132. package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
  133. package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
  134. package/src/components/custom/Workout/StatusPill.tsx +88 -0
  135. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
  136. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
  137. package/src/components/custom/Workout/TempoBar.stories.tsx +1 -1
  138. package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
  139. package/src/components/custom/Workout/TempoDisplay.tsx +23 -33
  140. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
  141. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
  142. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
  143. package/src/components/custom/Workout/VelocityStrip.stories.tsx +129 -66
  144. package/src/components/custom/Workout/VelocityStrip.test.tsx +376 -141
  145. package/src/components/custom/Workout/VelocityStrip.tsx +508 -120
  146. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
  147. package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
  148. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
  149. package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
  150. package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
  151. package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
  152. package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
  153. package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
  154. package/src/components/custom/Workout/ZoneTrack.stories.tsx +140 -0
  155. package/src/components/custom/Workout/ZoneTrack.test.tsx +200 -0
  156. package/src/components/custom/Workout/ZoneTrack.tsx +315 -0
  157. package/src/components/custom/Workout/index.ts +55 -2
  158. package/src/components/custom/Workout/metricText.stories.tsx +69 -0
  159. package/src/components/custom/Workout/metricText.tsx +34 -0
  160. package/src/components/custom/Workout/paceTone.test.ts +29 -0
  161. package/src/components/custom/Workout/paceTone.ts +27 -0
  162. package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
  163. package/src/components/custom/index.ts +1 -0
  164. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  165. package/src/components/icons/icons.stories.tsx +67 -2
  166. package/src/components/icons/icons.test.tsx +32 -2
  167. package/src/components/icons/icons.tsx +108 -0
  168. package/src/components/shell/BrandLockup.stories.tsx +3 -3
  169. package/src/components/shell/DashboardShell.stories.tsx +65 -0
  170. package/src/components/shell/DashboardShell.test.tsx +42 -0
  171. package/src/components/shell/DashboardShell.tsx +74 -0
  172. package/src/components/shell/DeviceIndicator.stories.tsx +2 -2
  173. package/src/components/shell/DeviceMenu.stories.tsx +5 -5
  174. package/src/components/shell/DeviceRow.stories.tsx +3 -3
  175. package/src/components/shell/NavItem.stories.tsx +52 -0
  176. package/src/components/shell/NavItem.test.tsx +35 -0
  177. package/src/components/shell/NavItem.tsx +75 -0
  178. package/src/components/shell/README.md +30 -0
  179. package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
  180. package/src/components/shell/SessionStatePill.stories.tsx +3 -3
  181. package/src/components/shell/SessionStatePill.tsx +1 -1
  182. package/src/components/shell/SideNav.stories.tsx +81 -0
  183. package/src/components/shell/SideNav.test.tsx +51 -0
  184. package/src/components/shell/SideNav.tsx +74 -0
  185. package/src/components/shell/TopBar.stories.tsx +5 -5
  186. package/src/components/shell/index.ts +5 -0
  187. package/src/components/ui/alert/Alert.stories.tsx +1 -1
  188. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  189. package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
  190. package/src/components/ui/badge/Badge.stories.tsx +1 -1
  191. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
  192. package/src/components/ui/button/Button.stories.tsx +1 -1
  193. package/src/components/ui/card/Card.stories.tsx +1 -1
  194. package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
  195. package/src/components/ui/chip/Chip.stories.tsx +1 -1
  196. package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
  197. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  198. package/src/components/ui/divider/Divider.stories.tsx +1 -1
  199. package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
  200. package/src/components/ui/form-field/FormField.stories.tsx +1 -1
  201. package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
  202. package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
  203. package/src/components/ui/index.ts +1 -0
  204. package/src/components/ui/indicator/Indicator.stories.tsx +2 -2
  205. package/src/components/ui/indicator/Indicator.test.tsx +1 -0
  206. package/src/components/ui/indicator/Indicator.tsx +6 -1
  207. package/src/components/ui/input/Input.stories.tsx +1 -1
  208. package/src/components/ui/link/Link.stories.tsx +1 -1
  209. package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
  210. package/src/components/ui/menu/Menu.stories.tsx +1 -1
  211. package/src/components/ui/modal/Modal.stories.tsx +1 -1
  212. package/src/components/ui/pill/Pill.stories.tsx +1 -1
  213. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  214. package/src/components/ui/progress/Progress.stories.tsx +1 -1
  215. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  216. package/src/components/ui/section/Section.stories.tsx +1 -1
  217. package/src/components/ui/select/Select.stories.tsx +1 -1
  218. package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
  219. package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
  220. package/src/components/ui/stack/Stack.stories.tsx +1 -1
  221. package/src/components/ui/surface/Surface.stories.tsx +1 -1
  222. package/src/components/ui/switch/Switch.stories.tsx +1 -1
  223. package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
  224. package/src/components/ui/tile/Tile.stories.tsx +79 -0
  225. package/src/components/ui/tile/Tile.test.tsx +48 -0
  226. package/src/components/ui/tile/Tile.tsx +64 -0
  227. package/src/components/ui/tile/index.ts +2 -0
  228. package/src/components/ui/toast/Toast.stories.tsx +1 -1
  229. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  230. package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
  231. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
  232. package/src/hooks/index.ts +2 -0
  233. package/src/hooks/useTimer.test.ts +150 -0
  234. package/src/hooks/useTimer.ts +77 -0
  235. package/src/index.ts +3 -0
  236. package/src/pages.ts +1 -1
  237. package/src/stories/PresetShowcase.stories.tsx +1 -1
  238. package/src/stories/ThemePresets.stories.tsx +1 -1
  239. package/src/theme/ColorSystem.stories.tsx +1 -1
  240. package/src/theme/Colors.stories.tsx +5 -8
  241. package/src/theme/config.ts +4 -0
  242. package/src/theme/elevation.stories.tsx +1 -1
  243. package/src/theme/global.css +5 -1
  244. package/src/theme/shadows.stories.tsx +1 -1
  245. package/src/theme/tokens/primitives.ts +11 -107
  246. package/src/theme/tokens/semantic.ts +8 -0
  247. package/src/theme/workout-tokens.ts +11 -7
  248. package/tailwind.config.js +5 -0
@@ -0,0 +1,73 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { VolumeLandmarkBar } from './VolumeLandmarkBar'
4
+
5
+ const meta: Meta<typeof VolumeLandmarkBar> = {
6
+ title: 'Workout/DataViz/VolumeLandmarkBar',
7
+ component: VolumeLandmarkBar,
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Horizontal weekly-volume bar with MEV / MAV / MRV landmark ticks, a HEAT-scale ' +
14
+ 'fill positioned against the MAV target, and a % readout. Reuses the canonical ' +
15
+ 'BodyMap volume heat scale (under → maintenance → productive → approaching → over).',
16
+ },
17
+ },
18
+ },
19
+ argTypes: {
20
+ muscle: { control: 'text', description: 'Muscle group label' },
21
+ currentSets: {
22
+ control: { type: 'range', min: 0, max: 30, step: 1 },
23
+ description: 'Current weekly working sets',
24
+ },
25
+ width: { control: 'number', description: 'Track width in px' },
26
+ trackHeight: { control: 'number', description: 'Track height in px' },
27
+ scaleMax: { control: 'number', description: 'Upper bound of the track scale (sets)' },
28
+ },
29
+ decorators: [
30
+ (Story) => (
31
+ <View style={{ padding: 24, paddingBottom: 40, backgroundColor: '#101010' }}>
32
+ <Story />
33
+ </View>
34
+ ),
35
+ ],
36
+ }
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof VolumeLandmarkBar>
40
+
41
+ const LANDMARKS = { mev: 8, mav: 16, mrv: 22 }
42
+
43
+ export const UnderMEV: Story = {
44
+ args: { muscle: 'Rear Delts', currentSets: 5, landmarks: LANDMARKS },
45
+ }
46
+
47
+ export const Maintenance: Story = {
48
+ args: { muscle: 'Calves', currentSets: 12, landmarks: LANDMARKS },
49
+ }
50
+
51
+ export const Optimal: Story = {
52
+ args: { muscle: 'Quads', currentSets: 17, landmarks: LANDMARKS },
53
+ }
54
+
55
+ export const ApproachingMRV: Story = {
56
+ args: { muscle: 'Back', currentSets: 21, landmarks: LANDMARKS },
57
+ }
58
+
59
+ export const OverMRV: Story = {
60
+ args: { muscle: 'Biceps', currentSets: 26, landmarks: LANDMARKS },
61
+ }
62
+
63
+ export const AllZones: Story = {
64
+ render: () => (
65
+ <View style={{ gap: 28 }}>
66
+ <VolumeLandmarkBar muscle="Rear Delts" currentSets={5} landmarks={LANDMARKS} />
67
+ <VolumeLandmarkBar muscle="Calves" currentSets={12} landmarks={LANDMARKS} />
68
+ <VolumeLandmarkBar muscle="Quads" currentSets={17} landmarks={LANDMARKS} />
69
+ <VolumeLandmarkBar muscle="Back" currentSets={21} landmarks={LANDMARKS} />
70
+ <VolumeLandmarkBar muscle="Biceps" currentSets={26} landmarks={LANDMARKS} />
71
+ </View>
72
+ ),
73
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { VolumeLandmarkBar, type VolumeLandmarks } from './VolumeLandmarkBar'
5
+
6
+ // Clean-number geometry: fraction(v) = v / scaleMax.
7
+ const LANDMARKS: VolumeLandmarks = { mev: 5, mav: 15, mrv: 20 }
8
+ const WIDTH = 200
9
+ const SCALE_MAX = 25
10
+
11
+ // HEAT scale hexes (WORKOUT_TOKENS.heatmap = divergingScale).
12
+ const HEAT = {
13
+ under: '#2196F3',
14
+ maintenance: '#22D3EE',
15
+ productive: '#58F69E',
16
+ approaching: '#F9B415',
17
+ over: '#D14343',
18
+ } as const
19
+
20
+ function renderBar(currentSets: number, muscle = 'Quads') {
21
+ return render(
22
+ <VolumeLandmarkBar
23
+ muscle={muscle}
24
+ currentSets={currentSets}
25
+ landmarks={LANDMARKS}
26
+ width={WIDTH}
27
+ scaleMax={SCALE_MAX}
28
+ />
29
+ )
30
+ }
31
+
32
+ describe('VolumeLandmarkBar', () => {
33
+ it('renders the bar, track (ZoneTrack), and fill', () => {
34
+ renderBar(10)
35
+ expect(screen.getByTestId('volume-landmark-bar')).toBeInTheDocument()
36
+ expect(screen.getByTestId('volume-landmark-track')).toBeInTheDocument()
37
+ expect(screen.getByTestId('zone-track-fill')).toBeInTheDocument()
38
+ })
39
+
40
+ it('shows the muscle title and % value in the header lockup', () => {
41
+ renderBar(15, 'Hamstrings')
42
+ expect(screen.getByText('Hamstrings')).toBeInTheDocument()
43
+ // 15 sets / 15 MAV = 100%
44
+ expect(screen.getByTestId('volume-landmark-pct')).toHaveTextContent('100%')
45
+ })
46
+
47
+ it('has the progressbar accessibility role', () => {
48
+ renderBar(10)
49
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
50
+ })
51
+
52
+ describe('percentage readout (relative to MAV target)', () => {
53
+ it('renders below-target percentages under 100', () => {
54
+ renderBar(3) // 3 / 15 = 20%
55
+ expect(screen.getByTestId('volume-landmark-pct')).toHaveTextContent('20%')
56
+ })
57
+ it('renders over-target percentages above 100', () => {
58
+ renderBar(24) // 24 / 15 = 160%
59
+ expect(screen.getByTestId('volume-landmark-pct')).toHaveTextContent('160%')
60
+ })
61
+ })
62
+
63
+ it('renders the MEV / MAV / MRV landmark tick labels', () => {
64
+ renderBar(10)
65
+ const labels = screen.getAllByTestId('zone-track-tick-label').map((el) => el.textContent)
66
+ expect(labels).toEqual(['MEV', 'MAV', 'MRV'])
67
+ })
68
+
69
+ describe('HEAT zone fill color by landmark position', () => {
70
+ const cases: Array<[number, keyof typeof HEAT]> = [
71
+ [3, 'under'],
72
+ [10, 'maintenance'],
73
+ [16, 'productive'],
74
+ [18, 'approaching'],
75
+ [25, 'over'],
76
+ ]
77
+ cases.forEach(([sets, zone]) => {
78
+ it(`is ${zone} at ${sets} sets`, () => {
79
+ renderBar(sets)
80
+ expect(screen.getByTestId('zone-track-fill')).toHaveStyle({ backgroundColor: HEAT[zone] })
81
+ })
82
+ })
83
+
84
+ it('mirrors the fill color in the percentage headline', () => {
85
+ renderBar(3)
86
+ expect(screen.getByTestId('volume-landmark-pct')).toHaveStyle({ color: HEAT.under })
87
+ })
88
+ })
89
+
90
+ it('sizes the fill proportionally (delegated to ZoneTrack)', () => {
91
+ renderBar(10) // 10 / 25 = 40% of the track
92
+ expect(screen.getByTestId('zone-track-fill')).toHaveStyle({ width: '40%' })
93
+ })
94
+
95
+ describe('productive-zone glow', () => {
96
+ it('glows the track in the productive zone', () => {
97
+ renderBar(16)
98
+ expect((screen.getByTestId('zone-track-track') as HTMLElement).style.boxShadow).not.toBe('')
99
+ })
100
+ it('does not glow outside the productive zone', () => {
101
+ renderBar(3)
102
+ expect((screen.getByTestId('zone-track-track') as HTMLElement).style.boxShadow).toBe('')
103
+ })
104
+ })
105
+
106
+ describe('accessibility', () => {
107
+ it('describes the muscle, sets, and zone in the label', () => {
108
+ renderBar(3, 'Calves')
109
+ expect(
110
+ screen.getByLabelText('Calves weekly volume: 3 sets, 20% of MAV target, below MEV')
111
+ ).toBeInTheDocument()
112
+ })
113
+
114
+ it('has no accessibility violations', async () => {
115
+ const { container } = renderBar(16)
116
+ expect(await axe(container)).toHaveNoViolations()
117
+ })
118
+ })
119
+ })
@@ -0,0 +1,154 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
4
+ import { primitiveColors } from '../../../theme/tokens/primitives'
5
+ import { ZoneTrack } from './ZoneTrack'
6
+ import { DataRow } from '../../ui/data-row/DataRow'
7
+ import type { VolumeLandmarks } from './muscleTaxonomy'
8
+
9
+ // Reuse the canonical BodyMap volume HEAT scale (the `divergingScale` under →
10
+ // optimal → over) rather than reinventing a fill ramp. Same color language as
11
+ // BodyMap / MesoProgressBar so a muscle reads identically across the app.
12
+ const HEAT = WORKOUT_TOKENS.heatmap
13
+
14
+ // The muted, un-reached track colour — the same charcoal step ZoneTrack defaults
15
+ // to, so the bar sits on the shared gauge-track surface.
16
+ const NEUTRAL_TRACK = primitiveColors.charcoal[200]
17
+ const MONO = 'monospace'
18
+
19
+ export type VolumeZone = 'under' | 'maintenance' | 'productive' | 'approaching' | 'over'
20
+
21
+ // The MEV/MAV/MRV shape is the canonical one from muscleTaxonomy — reused, not
22
+ // redefined, so there is a single source of truth. Re-exported for convenience.
23
+ export type { VolumeLandmarks }
24
+
25
+ export interface VolumeLandmarkBarProps extends ViewProps {
26
+ /** Muscle group label shown in the header lockup. */
27
+ muscle: string
28
+ /** Current weekly working sets for this muscle group. */
29
+ currentSets: number
30
+ /** MEV / MAV / MRV landmark set counts. */
31
+ landmarks: VolumeLandmarks
32
+ /** Track width in px (default 220). */
33
+ width?: number
34
+ /** Track height in px (default 10). */
35
+ trackHeight?: number
36
+ /**
37
+ * Upper bound of the track scale in sets. Defaults to `mrv` plus 20% headroom
38
+ * so an over-MRV fill visibly extends past the MRV tick.
39
+ */
40
+ scaleMax?: number
41
+ className?: string
42
+ }
43
+
44
+ const HEAT_BY_ZONE: Record<VolumeZone, string> = {
45
+ under: HEAT.under,
46
+ maintenance: HEAT.maintenance,
47
+ productive: HEAT.productive,
48
+ approaching: HEAT.approaching,
49
+ over: HEAT.over,
50
+ }
51
+
52
+ const ZONE_DESCRIPTION: Record<VolumeZone, string> = {
53
+ under: 'below MEV',
54
+ maintenance: 'building toward MAV',
55
+ productive: 'in the productive zone',
56
+ approaching: 'approaching MRV',
57
+ over: 'over MRV',
58
+ }
59
+
60
+ const LANDMARK_NAME: Record<'MEV' | 'MAV' | 'MRV', string> = {
61
+ MEV: 'Minimum Effective Volume',
62
+ MAV: 'Maximum Adaptive Volume',
63
+ MRV: 'Maximum Recoverable Volume',
64
+ }
65
+
66
+ /** Map weekly sets against the three landmarks to a diverging HEAT zone. */
67
+ function zoneForSets(sets: number, { mev, mav, mrv }: VolumeLandmarks): VolumeZone {
68
+ if (sets < mev) return 'under'
69
+ if (sets < mav) return 'maintenance'
70
+ if (sets >= mrv) return 'over'
71
+ // Between MAV and MRV: lower half is the optimal productive band, upper half
72
+ // approaches the recoverable ceiling.
73
+ const midpoint = (mav + mrv) / 2
74
+ return sets < midpoint ? 'productive' : 'approaching'
75
+ }
76
+
77
+ /**
78
+ * Horizontal weekly-volume bar with MEV / MAV / MRV landmark ticks and a HEAT-scale
79
+ * fill positioned against the MAV target. Composes the shared {@link ZoneTrack}
80
+ * gauge primitive (track + active-zone fill + colored/tooltip landmark ticks; glows
81
+ * when in the productive zone) and a {@link DataRow} header lockup (muscle title +
82
+ * current % at matched type height). The tick acronyms expand to their full name +
83
+ * raw set count on hover/long-press, keeping the footer light. Reuses the canonical
84
+ * BodyMap volume heat scale so a muscle's status reads the same as in the body map.
85
+ *
86
+ * @example
87
+ * <VolumeLandmarkBar muscle="Quads" currentSets={16} landmarks={{ mev: 8, mav: 16, mrv: 22 }} />
88
+ */
89
+ export function VolumeLandmarkBar({
90
+ muscle,
91
+ currentSets,
92
+ landmarks,
93
+ width = 220,
94
+ trackHeight = 10,
95
+ scaleMax,
96
+ className,
97
+ style,
98
+ ...props
99
+ }: VolumeLandmarkBarProps) {
100
+ const { mev, mav, mrv } = landmarks
101
+ const max = scaleMax ?? mrv * 1.2
102
+ const zone = zoneForSets(currentSets, landmarks)
103
+ const fillColor = HEAT_BY_ZONE[zone]
104
+ const pct = mav > 0 ? Math.round((currentSets / mav) * 100) : 0
105
+
106
+ return (
107
+ <View
108
+ className={className}
109
+ style={[{ width, gap: 3 }, style]}
110
+ testID="volume-landmark-bar"
111
+ {...props}
112
+ >
113
+ <DataRow
114
+ label={muscle}
115
+ value={
116
+ <Text
117
+ testID="volume-landmark-pct"
118
+ style={{ fontSize: 14, fontWeight: '700', fontFamily: MONO, color: fillColor }}
119
+ >
120
+ {pct}%
121
+ </Text>
122
+ }
123
+ className="py-0"
124
+ testID="volume-landmark-header"
125
+ />
126
+
127
+ <ZoneTrack
128
+ zones={[{ upTo: max, color: NEUTRAL_TRACK }]}
129
+ max={max}
130
+ marker={{
131
+ type: 'fill',
132
+ value: currentSets,
133
+ color: fillColor,
134
+ // Glow when in the optimal productive band — the "sweet spot" cue.
135
+ glow: zone === 'productive',
136
+ }}
137
+ trackColor={NEUTRAL_TRACK}
138
+ trackHeight={trackHeight}
139
+ ticks={[
140
+ { value: mev, label: 'MEV', tooltip: `${LANDMARK_NAME.MEV} · ${mev} sets/wk` },
141
+ {
142
+ value: mav,
143
+ label: 'MAV',
144
+ emphasized: true,
145
+ tooltip: `${LANDMARK_NAME.MAV} (target) · ${mav} sets/wk`,
146
+ },
147
+ { value: mrv, label: 'MRV', tooltip: `${LANDMARK_NAME.MRV} · ${mrv} sets/wk` },
148
+ ]}
149
+ accessibilityLabel={`${muscle} weekly volume: ${currentSets} sets, ${pct}% of MAV target, ${ZONE_DESCRIPTION[zone]}`}
150
+ testID="volume-landmark-track"
151
+ />
152
+ </View>
153
+ )
154
+ }
@@ -3,7 +3,7 @@ import { View } from 'react-native'
3
3
  import { WeekRow } from './WeekRow'
4
4
 
5
5
  const meta: Meta<typeof WeekRow> = {
6
- title: 'Custom/Workout/WeekRow',
6
+ title: 'Workout/WeekRow',
7
7
  component: WeekRow,
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
@@ -3,7 +3,7 @@ import { View } from 'react-native'
3
3
  import { WeightBadge } from './WeightBadge'
4
4
 
5
5
  const meta: Meta<typeof WeightBadge> = {
6
- title: 'Custom/Workout/WeightBadge',
6
+ title: 'Components/Atoms/WeightBadge',
7
7
  component: WeightBadge,
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
@@ -4,7 +4,7 @@ import { WorkoutCard } from './WorkoutCard'
4
4
  import type { ExerciseCardProps } from './ExerciseCard'
5
5
 
6
6
  const meta: Meta<typeof WorkoutCard> = {
7
- title: 'Custom/Workout/WorkoutCard',
7
+ title: 'Workout/WorkoutCard',
8
8
  component: WorkoutCard,
9
9
  tags: ['autodocs'],
10
10
  argTypes: {
@@ -38,10 +38,7 @@ type Story = StoryObj<typeof WorkoutCard>
38
38
  const sampleExercises: ExerciseCardProps[] = [
39
39
  {
40
40
  name: 'Bench Press',
41
- state: 'collapsed',
42
- onToggle: () => {},
43
41
  summary: { sets: 3, reps: 8, weight: 175, unit: 'lbs' },
44
- e1rm: { value: 225, unit: 'lbs' },
45
42
  setVelocities: [
46
43
  [1.1, 0.95, 0.82],
47
44
  [0.95, 0.88, 0.78],
@@ -50,8 +47,7 @@ const sampleExercises: ExerciseCardProps[] = [
50
47
  },
51
48
  {
52
49
  name: 'Incline DB Press',
53
- state: 'upcoming',
54
- onToggle: () => {},
50
+ upcoming: true,
55
51
  prescription: '3×10-12 @ RPE 8',
56
52
  previousBest: '70 lbs × 11',
57
53
  },
@@ -19,14 +19,11 @@ const baseProps: WorkoutCardProps = {
19
19
  const exercises: ExerciseCardProps[] = [
20
20
  {
21
21
  name: 'Bench Press',
22
- state: 'collapsed',
23
- onToggle: () => {},
24
22
  summary: { sets: 3, reps: 8, weight: 175, unit: 'lbs' },
25
23
  },
26
24
  {
27
25
  name: 'Row',
28
- state: 'upcoming',
29
- onToggle: () => {},
26
+ upcoming: true,
30
27
  prescription: '3×10 @ RPE 8',
31
28
  },
32
29
  ]
@@ -35,40 +32,28 @@ describe('WorkoutCard', () => {
35
32
  describe('rendering', () => {
36
33
  it('renders name and date', () => {
37
34
  render(<WorkoutCard {...baseProps} />)
38
- expect(screen.getByTestId('workout-card-name')).toHaveTextContent(
39
- 'Upper A',
40
- )
41
- expect(screen.getByTestId('workout-card-date')).toHaveTextContent(
42
- 'Mon, Jun 23',
43
- )
35
+ expect(screen.getByTestId('workout-card-name')).toHaveTextContent('Upper A')
36
+ expect(screen.getByTestId('workout-card-date')).toHaveTextContent('Mon, Jun 23')
44
37
  })
45
38
 
46
39
  it('renders the set count in the stats row', () => {
47
40
  render(<WorkoutCard {...baseProps} />)
48
- expect(screen.getByTestId('workout-card-stats')).toHaveTextContent(
49
- '18 sets',
50
- )
41
+ expect(screen.getByTestId('workout-card-stats')).toHaveTextContent('18 sets')
51
42
  })
52
43
 
53
44
  it('includes duration in stats when provided', () => {
54
45
  render(<WorkoutCard {...baseProps} duration="45 min" />)
55
- expect(screen.getByTestId('workout-card-stats')).toHaveTextContent(
56
- '45 min',
57
- )
46
+ expect(screen.getByTestId('workout-card-stats')).toHaveTextContent('45 min')
58
47
  })
59
48
 
60
49
  it('includes total volume with unit when provided', () => {
61
50
  render(<WorkoutCard {...baseProps} totalVolume={12450} unit="lbs" />)
62
- expect(screen.getByTestId('workout-card-stats')).toHaveTextContent(
63
- '12450 lbs',
64
- )
51
+ expect(screen.getByTestId('workout-card-stats')).toHaveTextContent('12450 lbs')
65
52
  })
66
53
 
67
54
  it('omits volume from stats when not provided', () => {
68
55
  render(<WorkoutCard {...baseProps} />)
69
- expect(screen.getByTestId('workout-card-stats')).not.toHaveTextContent(
70
- 'lbs',
71
- )
56
+ expect(screen.getByTestId('workout-card-stats')).not.toHaveTextContent('lbs')
72
57
  })
73
58
  })
74
59
 
@@ -87,38 +72,22 @@ describe('WorkoutCard', () => {
87
72
 
88
73
  it('does not render the muscle group row when empty', () => {
89
74
  render(<WorkoutCard {...baseProps} muscleGroups={[]} />)
90
- expect(
91
- screen.queryByTestId('workout-card-muscle-groups'),
92
- ).not.toBeInTheDocument()
75
+ expect(screen.queryByTestId('workout-card-muscle-groups')).not.toBeInTheDocument()
93
76
  })
94
77
  })
95
78
 
96
79
  describe('expansion', () => {
97
80
  it('renders exercises when expanded', () => {
98
- render(
99
- <WorkoutCard
100
- {...baseProps}
101
- expanded
102
- onToggle={() => {}}
103
- exercises={exercises}
104
- />,
105
- )
81
+ render(<WorkoutCard {...baseProps} expanded onToggle={() => {}} exercises={exercises} />)
106
82
  expect(screen.getByTestId('workout-card-exercises')).toBeInTheDocument()
107
83
  expect(screen.getAllByTestId('exercise-card')).toHaveLength(2)
108
84
  })
109
85
 
110
86
  it('does not render exercises when collapsed', () => {
111
87
  render(
112
- <WorkoutCard
113
- {...baseProps}
114
- expanded={false}
115
- onToggle={() => {}}
116
- exercises={exercises}
117
- />,
88
+ <WorkoutCard {...baseProps} expanded={false} onToggle={() => {}} exercises={exercises} />
118
89
  )
119
- expect(
120
- screen.queryByTestId('workout-card-exercises'),
121
- ).not.toBeInTheDocument()
90
+ expect(screen.queryByTestId('workout-card-exercises')).not.toBeInTheDocument()
122
91
  })
123
92
 
124
93
  it('calls onToggle when pressed', () => {
@@ -145,9 +114,7 @@ describe('WorkoutCard', () => {
145
114
 
146
115
  describe('accessibility', () => {
147
116
  it('exposes button role and expanded state when expandable', () => {
148
- render(
149
- <WorkoutCard {...baseProps} expanded onToggle={() => {}} />,
150
- )
117
+ render(<WorkoutCard {...baseProps} expanded onToggle={() => {}} />)
151
118
  const card = screen.getByRole('button', {
152
119
  name: 'Upper A workout, Mon, Jun 23, today',
153
120
  })
@@ -163,7 +130,7 @@ describe('WorkoutCard', () => {
163
130
  onToggle={() => {}}
164
131
  expanded
165
132
  exercises={exercises}
166
- />,
133
+ />
167
134
  )
168
135
  const results = await axe(container)
169
136
  expect(results).toHaveNoViolations()
@@ -3,7 +3,7 @@ import { View } from 'react-native'
3
3
  import { WorkoutPill } from './WorkoutPill'
4
4
 
5
5
  const meta: Meta<typeof WorkoutPill> = {
6
- title: 'Custom/Workout/WorkoutPill',
6
+ title: 'Workout/WorkoutPill',
7
7
  component: WorkoutPill,
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
@@ -0,0 +1,140 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View } from 'react-native'
4
+ import { ZoneTrack, type ZoneTrackZone } from './ZoneTrack'
5
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
6
+
7
+ const { green, yellow, orange, red } = WORKOUT_TOKENS.scale
8
+
9
+ const FATIGUE_ZONES: ZoneTrackZone[] = [
10
+ { upTo: 10, color: green },
11
+ { upTo: 20, color: yellow },
12
+ { upTo: 30, color: orange },
13
+ { upTo: 40, color: red },
14
+ ]
15
+
16
+ const FATIGUE_TICKS = [
17
+ { value: 0, label: 'fresh' },
18
+ { value: 10, label: 'VL10' },
19
+ { value: 20, label: 'VL20' },
20
+ { value: 30, label: 'VL30' },
21
+ { value: 40, label: 'stop' },
22
+ ]
23
+
24
+ const meta: Meta<typeof ZoneTrack> = {
25
+ title: 'Workout/DataViz/ZoneTrack',
26
+ component: ZoneTrack,
27
+ tags: ['autodocs'],
28
+ parameters: {
29
+ docs: {
30
+ description: {
31
+ component:
32
+ '**Primitive.** The shared linear-gauge base: a pill track with an N-band zone ' +
33
+ 'gradient, optional tick marks + labels, and a single value marker — a `needle` line ' +
34
+ 'or a left-anchored `fill` (clip-reveal gradient, or solid trend colour). Used-by ↓ ' +
35
+ '[FatigueMeter](?path=/docs/workout-dataviz-fatiguemeter--docs); the target base for ' +
36
+ 'TrainingLoadGauge (ACWR) + RpeCalibration (band + marker). Zone colours are literal ' +
37
+ 'ramp-token hex.',
38
+ },
39
+ },
40
+ },
41
+ decorators: [
42
+ (Story) => (
43
+ <View style={{ width: 360, padding: 20, backgroundColor: '#131313' }}>
44
+ <Story />
45
+ </View>
46
+ ),
47
+ ],
48
+ }
49
+ export default meta
50
+
51
+ type Story = StoryObj<typeof ZoneTrack>
52
+
53
+ /** Needle over the fatigue gradient with the VL scale — the FatigueMeter shape. */
54
+ export const Needle: Story = {
55
+ args: {
56
+ zones: FATIGUE_ZONES,
57
+ max: 40,
58
+ marker: { type: 'needle', value: 21 },
59
+ ticks: FATIGUE_TICKS,
60
+ },
61
+ }
62
+
63
+ /** Fill marker without a colour clip-reveals the gradient up to the value. */
64
+ export const GradientFill: Story = {
65
+ args: {
66
+ zones: FATIGUE_ZONES,
67
+ max: 40,
68
+ marker: { type: 'fill', value: 24 },
69
+ ticks: FATIGUE_TICKS,
70
+ },
71
+ }
72
+
73
+ /** Fill marker with a colour paints a solid trend-coloured bar (FatigueGauge lineage). */
74
+ export const SolidTrendFill: Story = {
75
+ args: { zones: FATIGUE_ZONES, max: 40, marker: { type: 'fill', value: 24, color: orange } },
76
+ }
77
+
78
+ /** A range highlight + needle — the RpeCalibration "predicted CI band vs actual" idiom. */
79
+ export const BandWithMarker: Story = {
80
+ args: {
81
+ zones: [
82
+ { upTo: 7, color: green },
83
+ { upTo: 8.5, color: yellow },
84
+ { upTo: 10, color: red },
85
+ ],
86
+ min: 6,
87
+ max: 10,
88
+ band: { from: 7.2, to: 8.4, color: 'rgba(255,255,255,0.22)' },
89
+ marker: { type: 'needle', value: 8.1 },
90
+ ticks: [
91
+ { value: 6, label: '6' },
92
+ { value: 7, label: '7' },
93
+ { value: 8, label: '8' },
94
+ { value: 9, label: '9' },
95
+ { value: 10, label: '10' },
96
+ ],
97
+ },
98
+ }
99
+
100
+ /** Bare gradient track — no marker, no ticks. */
101
+ export const BareTrack: Story = {
102
+ args: { zones: FATIGUE_ZONES, max: 40 },
103
+ }
104
+
105
+ /** Chunky wall-glanceable density via a taller track. */
106
+ export const WallDensity: Story = {
107
+ args: {
108
+ zones: FATIGUE_ZONES,
109
+ max: 40,
110
+ marker: { type: 'needle', value: 15 },
111
+ ticks: FATIGUE_TICKS,
112
+ trackHeight: 22,
113
+ needleOverhang: 9,
114
+ },
115
+ }
116
+
117
+ /** `marker.glow` haloes the whole track in the marker colour — a "sweet spot" / attention cue. */
118
+ export const Glow: Story = {
119
+ args: {
120
+ zones: FATIGUE_ZONES,
121
+ max: 40,
122
+ marker: { type: 'fill', value: 20, color: green, glow: true },
123
+ trackHeight: 12,
124
+ },
125
+ }
126
+
127
+ /** Colored + emphasized ticks with tooltips — hover a label to expand the acronym / show the raw value. */
128
+ export const ColoredTooltipTicks: Story = {
129
+ args: {
130
+ zones: FATIGUE_ZONES,
131
+ max: 40,
132
+ marker: { type: 'needle', value: 22 },
133
+ trackHeight: 12,
134
+ ticks: [
135
+ { value: 10, label: 'VL10', tooltip: '10% velocity loss' },
136
+ { value: 20, label: 'VL20', emphasized: true, tooltip: '20% velocity loss — threshold' },
137
+ { value: 30, label: 'STOP', color: red, tooltip: '30% velocity loss — stop the set' },
138
+ ],
139
+ },
140
+ }