@titan-design/react-ui 0.2.6 → 0.3.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 (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,152 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View } from 'react-native'
4
+ import { MesoCard } from './MesoCard'
5
+ import type { WeekRowProps } from './WeekRow'
6
+
7
+ const weeks: WeekRowProps[] = [
8
+ {
9
+ weekNumber: 1,
10
+ totalWeeks: 4,
11
+ workouts: [
12
+ { name: 'Upper', status: 'completed' },
13
+ { name: 'Lower', status: 'completed' },
14
+ ],
15
+ intensityLevel: 0.4,
16
+ intensityThreshold: 0.85,
17
+ },
18
+ {
19
+ weekNumber: 2,
20
+ totalWeeks: 4,
21
+ workouts: [
22
+ { name: 'Upper', status: 'current' },
23
+ { name: 'Lower', status: 'upcoming' },
24
+ ],
25
+ intensityLevel: 0.6,
26
+ intensityThreshold: 0.85,
27
+ },
28
+ {
29
+ weekNumber: 3,
30
+ totalWeeks: 4,
31
+ workouts: [
32
+ { name: 'Upper', status: 'upcoming' },
33
+ { name: 'Lower', status: 'upcoming' },
34
+ ],
35
+ intensityLevel: 0.8,
36
+ intensityThreshold: 0.85,
37
+ },
38
+ {
39
+ weekNumber: 4,
40
+ totalWeeks: 4,
41
+ workouts: [
42
+ { name: 'Upper', status: 'upcoming' },
43
+ { name: 'Lower', status: 'upcoming' },
44
+ ],
45
+ intensityLevel: 0.35,
46
+ isDeload: true,
47
+ },
48
+ ]
49
+
50
+ const meta: Meta<typeof MesoCard> = {
51
+ title: 'Custom/Workout/MesoCard',
52
+ component: MesoCard,
53
+ tags: ['autodocs'],
54
+ argTypes: {
55
+ goal: {
56
+ control: 'select',
57
+ options: ['Hypertrophy', 'Strength', 'Peaking'],
58
+ description: 'Training goal shown in the badge',
59
+ },
60
+ expanded: {
61
+ control: 'boolean',
62
+ description: 'Whether the week list is revealed',
63
+ },
64
+ highlighted: {
65
+ control: 'boolean',
66
+ description: 'Synced highlight with the MesoProgressBar',
67
+ },
68
+ },
69
+ }
70
+
71
+ export default meta
72
+ type Story = StoryObj<typeof MesoCard>
73
+
74
+ export const Collapsed: Story = {
75
+ args: {
76
+ name: 'Accumulation',
77
+ goal: 'Hypertrophy',
78
+ split: 'Upper/Lower',
79
+ weekRange: 'Weeks 1-4',
80
+ currentWeek: 2,
81
+ totalWeeks: 4,
82
+ weeks,
83
+ volumeHeatmap: [
84
+ { group: 'chest', percentage: 80 },
85
+ { group: 'back', percentage: 70 },
86
+ { group: 'legs', percentage: 90 },
87
+ { group: 'shoulders', percentage: 45 },
88
+ { group: 'arms', percentage: 35 },
89
+ ],
90
+ },
91
+ }
92
+
93
+ export const Expanded: Story = {
94
+ args: {
95
+ ...Collapsed.args,
96
+ expanded: true,
97
+ },
98
+ }
99
+
100
+ export const Highlighted: Story = {
101
+ args: {
102
+ ...Collapsed.args,
103
+ expanded: true,
104
+ highlighted: true,
105
+ },
106
+ }
107
+
108
+ export const StrengthBlock: Story = {
109
+ args: {
110
+ name: 'Intensification',
111
+ goal: 'Strength',
112
+ split: 'Push/Pull/Legs',
113
+ weekRange: 'Weeks 5-8',
114
+ currentWeek: 6,
115
+ totalWeeks: 4,
116
+ weeks: weeks.map((w) => ({ ...w, weekNumber: w.weekNumber + 4 })),
117
+ volumeHeatmap: [
118
+ { group: 'chest', percentage: 60 },
119
+ { group: 'back', percentage: 65 },
120
+ { group: 'legs', percentage: 75 },
121
+ ],
122
+ expanded: true,
123
+ },
124
+ }
125
+
126
+ function InteractiveMesoCard() {
127
+ const [expanded, setExpanded] = useState(false)
128
+ return (
129
+ <View style={{ maxWidth: 420, padding: 16 }}>
130
+ <MesoCard
131
+ name="Accumulation"
132
+ goal="Hypertrophy"
133
+ split="Upper/Lower"
134
+ weekRange="Weeks 1-4"
135
+ currentWeek={2}
136
+ totalWeeks={4}
137
+ weeks={weeks}
138
+ volumeHeatmap={[
139
+ { group: 'chest', percentage: 80 },
140
+ { group: 'back', percentage: 70 },
141
+ { group: 'legs', percentage: 90 },
142
+ ]}
143
+ expanded={expanded}
144
+ onToggle={() => setExpanded((v) => !v)}
145
+ />
146
+ </View>
147
+ )
148
+ }
149
+
150
+ export const Interactive: Story = {
151
+ render: () => <InteractiveMesoCard />,
152
+ }
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { MesoCard } from './MesoCard'
5
+ import type { WeekRowProps } from './WeekRow'
6
+
7
+ const weeks: WeekRowProps[] = [
8
+ {
9
+ weekNumber: 1,
10
+ totalWeeks: 4,
11
+ workouts: [
12
+ { name: 'Upper', status: 'completed' },
13
+ { name: 'Lower', status: 'completed' },
14
+ ],
15
+ intensityLevel: 0.4,
16
+ },
17
+ {
18
+ weekNumber: 2,
19
+ totalWeeks: 4,
20
+ workouts: [
21
+ { name: 'Upper', status: 'current' },
22
+ { name: 'Lower', status: 'upcoming' },
23
+ ],
24
+ intensityLevel: 0.6,
25
+ },
26
+ ]
27
+
28
+ const baseProps = {
29
+ name: 'Accumulation',
30
+ goal: 'Hypertrophy',
31
+ split: 'Upper/Lower',
32
+ weekRange: 'Weeks 1-4',
33
+ totalWeeks: 4,
34
+ weeks,
35
+ }
36
+
37
+ describe('MesoCard', () => {
38
+ describe('rendering', () => {
39
+ it('renders name, goal badge, and sub-header', () => {
40
+ render(<MesoCard {...baseProps} />)
41
+ expect(screen.getByTestId('meso-card')).toBeInTheDocument()
42
+ expect(screen.getByTestId('meso-card-name')).toHaveTextContent(
43
+ 'Accumulation',
44
+ )
45
+ expect(screen.getByTestId('meso-card-goal-badge')).toHaveTextContent(
46
+ 'Hypertrophy',
47
+ )
48
+ expect(screen.getByTestId('meso-card-subheader')).toHaveTextContent(
49
+ 'Upper/Lower · Weeks 1-4',
50
+ )
51
+ })
52
+
53
+ it('renders the 3px gradient top accent', () => {
54
+ render(<MesoCard {...baseProps} />)
55
+ expect(screen.getByTestId('meso-card-accent')).toBeInTheDocument()
56
+ })
57
+ })
58
+
59
+ describe('expansion', () => {
60
+ it('does not render week list when collapsed', () => {
61
+ render(<MesoCard {...baseProps} />)
62
+ expect(screen.queryByTestId('meso-card-weeks')).not.toBeInTheDocument()
63
+ })
64
+
65
+ it('renders the week list when expanded', () => {
66
+ render(<MesoCard {...baseProps} expanded />)
67
+ expect(screen.getByTestId('meso-card-weeks')).toBeInTheDocument()
68
+ expect(screen.getAllByTestId('week-row')).toHaveLength(2)
69
+ })
70
+
71
+ it('marks header as a button and fires onToggle', () => {
72
+ const onToggle = vi.fn()
73
+ render(<MesoCard {...baseProps} onToggle={onToggle} />)
74
+ const toggle = screen.getByTestId('meso-card-toggle')
75
+ expect(toggle).toHaveAttribute('aria-expanded', 'false')
76
+ fireEvent.click(toggle)
77
+ expect(onToggle).toHaveBeenCalledOnce()
78
+ })
79
+
80
+ it('reflects expanded state via aria-expanded', () => {
81
+ const onToggle = vi.fn()
82
+ render(<MesoCard {...baseProps} onToggle={onToggle} expanded />)
83
+ expect(screen.getByTestId('meso-card-toggle')).toHaveAttribute(
84
+ 'aria-expanded',
85
+ 'true',
86
+ )
87
+ })
88
+
89
+ it('renders a static container when not expandable', () => {
90
+ render(<MesoCard {...baseProps} />)
91
+ expect(screen.getByTestId('meso-card-static')).toBeInTheDocument()
92
+ expect(screen.queryByTestId('meso-card-toggle')).not.toBeInTheDocument()
93
+ })
94
+
95
+ it('derives current week marker from currentWeek prop', () => {
96
+ render(<MesoCard {...baseProps} currentWeek={2} expanded />)
97
+ expect(screen.getByTestId('week-row-current-dot')).toBeInTheDocument()
98
+ })
99
+ })
100
+
101
+ describe('volume heatmap', () => {
102
+ it('renders a heatmap cell per muscle group', () => {
103
+ render(
104
+ <MesoCard
105
+ {...baseProps}
106
+ volumeHeatmap={[
107
+ { group: 'chest', percentage: 80 },
108
+ { group: 'back', percentage: 50 },
109
+ { group: 'legs', percentage: 30 },
110
+ ]}
111
+ />,
112
+ )
113
+ expect(screen.getByTestId('meso-card-heatmap')).toBeInTheDocument()
114
+ expect(screen.getAllByTestId('meso-card-heatmap-cell')).toHaveLength(3)
115
+ })
116
+
117
+ it('does not render heatmap when no data provided', () => {
118
+ render(<MesoCard {...baseProps} />)
119
+ expect(screen.queryByTestId('meso-card-heatmap')).not.toBeInTheDocument()
120
+ })
121
+ })
122
+
123
+ describe('highlighted state', () => {
124
+ it('applies a brand-primary border color when highlighted', () => {
125
+ render(<MesoCard {...baseProps} highlighted />)
126
+ const card = screen.getByTestId('meso-card')
127
+ const style = card.getAttribute('style') ?? ''
128
+ expect(style).toContain('box-shadow')
129
+ })
130
+ })
131
+
132
+ describe('accessibility', () => {
133
+ it('has an accessible label describing the meso', () => {
134
+ render(<MesoCard {...baseProps} />)
135
+ expect(
136
+ screen.getByLabelText('Accumulation, Hypertrophy, Weeks 1-4'),
137
+ ).toBeInTheDocument()
138
+ })
139
+
140
+ it('exposes the label on the button when expandable', () => {
141
+ render(<MesoCard {...baseProps} onToggle={vi.fn()} />)
142
+ const toggle = screen.getByLabelText('Accumulation, Hypertrophy, Weeks 1-4')
143
+ expect(toggle).toHaveAttribute('role', 'button')
144
+ })
145
+
146
+ it('has no accessibility violations', async () => {
147
+ const { container } = render(<MesoCard {...baseProps} />)
148
+ const results = await axe(container)
149
+ expect(results).toHaveNoViolations()
150
+ })
151
+
152
+ it('has no accessibility violations when expanded with all features', async () => {
153
+ const { container } = render(
154
+ <MesoCard
155
+ {...baseProps}
156
+ currentWeek={2}
157
+ onToggle={vi.fn()}
158
+ expanded
159
+ highlighted
160
+ volumeHeatmap={[
161
+ { group: 'chest', percentage: 80 },
162
+ { group: 'back', percentage: 50 },
163
+ ]}
164
+ />,
165
+ )
166
+ const results = await axe(container)
167
+ expect(results).toHaveNoViolations()
168
+ })
169
+ })
170
+ })
@@ -0,0 +1,237 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { Card } from '../../ui/card'
5
+ import { Badge } from '../../ui/badge'
6
+ import { WeekRow, type WeekRowProps } from './WeekRow'
7
+
8
+ const BRAND_PRIMARY = '#FF7900'
9
+ const BRAND_PRIMARY_DARK = '#C45E00'
10
+ const BRAND_PRIMARY_LIGHT = '#FFB066'
11
+ const BORDER_DEFAULT = '#1F1F1F'
12
+ // Theme-aware foregrounds: resolve to a readable color in both light and dark
13
+ // (react-native-web passes the CSS var through to the inline style).
14
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
15
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
16
+
17
+ /** Gradient stops for the 3px top accent: dark -> primary -> light. */
18
+ const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
19
+
20
+ export interface MesoVolumeHeatmapEntry {
21
+ /** Muscle group identifier (free-form to match plan data). */
22
+ group: string
23
+ /** Planned volume intensity for the group, 0-100. */
24
+ percentage: number
25
+ }
26
+
27
+ export interface MesoCardProps extends ViewProps {
28
+ /** Mesocycle name, e.g. "Accumulation". */
29
+ name: string
30
+ /** Training goal, e.g. "Hypertrophy", "Strength", "Peaking". */
31
+ goal: string
32
+ /** Training split, e.g. "Upper/Lower", "Push/Pull/Legs". */
33
+ split: string
34
+ /** Week range label, e.g. "Weeks 1-4". */
35
+ weekRange: string
36
+ /** Current week within the mesocycle (1-based). */
37
+ currentWeek?: number
38
+ /** Total weeks in the mesocycle. */
39
+ totalWeeks: number
40
+ /** Weeks rendered as a WeekRow list when expanded. */
41
+ weeks: WeekRowProps[]
42
+ /** Whether the card is expanded to reveal the week list. */
43
+ expanded?: boolean
44
+ /** Toggles expansion; makes the header a button when provided. */
45
+ onToggle?: () => void
46
+ /** Whether this meso is highlighted in the MesoProgressBar. */
47
+ highlighted?: boolean
48
+ /** Volume heatmap data per muscle group for the meso. */
49
+ volumeHeatmap?: MesoVolumeHeatmapEntry[]
50
+ className?: string
51
+ }
52
+
53
+ /**
54
+ * Maps a 0-100 volume percentage onto a brand-primary tint whose opacity
55
+ * tracks intensity, keeping a visible floor so low-volume groups stay legible.
56
+ */
57
+ function heatmapColor(percentage: number): string {
58
+ const clamped = Math.max(0, Math.min(100, percentage))
59
+ const opacity = 0.12 + (clamped / 100) * 0.78
60
+ return `rgba(255,121,0,${opacity.toFixed(3)})`
61
+ }
62
+
63
+ /**
64
+ * A mesocycle card with name, goal, split, week range, an optional volume
65
+ * heatmap strip, and an expandable WeekRow list. Highlighting animates the
66
+ * border toward brand-primary with a subtle glow to stay in sync with the
67
+ * MesoProgressBar.
68
+ *
69
+ * @example
70
+ * <MesoCard
71
+ * name="Accumulation"
72
+ * goal="Hypertrophy"
73
+ * split="Upper/Lower"
74
+ * weekRange="Weeks 1-4"
75
+ * currentWeek={2}
76
+ * totalWeeks={4}
77
+ * weeks={weeks}
78
+ * volumeHeatmap={[{ group: 'chest', percentage: 80 }]}
79
+ * expanded
80
+ * onToggle={() => {}}
81
+ * highlighted
82
+ * />
83
+ */
84
+ export function MesoCard({
85
+ name,
86
+ goal,
87
+ split,
88
+ weekRange,
89
+ currentWeek,
90
+ totalWeeks,
91
+ weeks,
92
+ expanded = false,
93
+ onToggle,
94
+ highlighted = false,
95
+ volumeHeatmap,
96
+ className,
97
+ ...props
98
+ }: MesoCardProps) {
99
+ const isExpandable = onToggle != null
100
+ const [highlight] = useState(() => new Animated.Value(highlighted ? 1 : 0))
101
+
102
+ useEffect(() => {
103
+ const animation = Animated.timing(highlight, {
104
+ toValue: highlighted ? 1 : 0,
105
+ duration: 250,
106
+ easing: Easing.out(Easing.ease),
107
+ useNativeDriver: false,
108
+ })
109
+ animation.start()
110
+ return () => animation.stop()
111
+ }, [highlighted, highlight])
112
+
113
+ const borderColor = highlight.interpolate({
114
+ inputRange: [0, 1],
115
+ outputRange: [BORDER_DEFAULT, BRAND_PRIMARY],
116
+ })
117
+
118
+ const header = (
119
+ <View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 10 }} testID="meso-card-body">
120
+ <View className="flex-row items-center" style={{ gap: 8 }} testID="meso-card-header">
121
+ <Text
122
+ style={{
123
+ fontSize: 15,
124
+ fontFamily: '"Space Grotesk", sans-serif',
125
+ fontWeight: '700',
126
+ color: TEXT_PRIMARY,
127
+ }}
128
+ testID="meso-card-name"
129
+ >
130
+ {name}
131
+ </Text>
132
+ <Badge variant="subtle" color="primary" size="sm" testID="meso-card-goal-badge">
133
+ {goal}
134
+ </Badge>
135
+ </View>
136
+
137
+ <Text
138
+ style={{
139
+ marginTop: 4,
140
+ fontSize: 12,
141
+ fontFamily: 'Inter, sans-serif',
142
+ color: TEXT_SECONDARY,
143
+ }}
144
+ testID="meso-card-subheader"
145
+ >
146
+ {`${split} · ${weekRange}`}
147
+ </Text>
148
+
149
+ {volumeHeatmap && volumeHeatmap.length > 0 && (
150
+ <View
151
+ className="flex-row items-center"
152
+ style={{ marginTop: 10, gap: 3 }}
153
+ accessibilityElementsHidden
154
+ testID="meso-card-heatmap"
155
+ >
156
+ {volumeHeatmap.map((entry) => (
157
+ <View
158
+ key={entry.group}
159
+ style={{
160
+ flex: 1,
161
+ height: 8,
162
+ borderRadius: 2,
163
+ backgroundColor: heatmapColor(entry.percentage),
164
+ }}
165
+ testID="meso-card-heatmap-cell"
166
+ />
167
+ ))}
168
+ </View>
169
+ )}
170
+ </View>
171
+ )
172
+
173
+ return (
174
+ <Animated.View style={highlighted ? { transform: [{ scale: 1.0 }] } : undefined} testID="meso-card-wrapper">
175
+ <Card
176
+ variant="outline"
177
+ elevation={2}
178
+ borderColor={highlighted ? BRAND_PRIMARY : BORDER_DEFAULT}
179
+ className={className}
180
+ style={[
181
+ { borderColor: borderColor as unknown as string },
182
+ highlighted
183
+ ? { boxShadow: '0 0 12px 2px rgba(255,121,0,0.25)' }
184
+ : undefined,
185
+ ]}
186
+ testID="meso-card"
187
+ {...props}
188
+ >
189
+ <View
190
+ className="flex-row"
191
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, zIndex: 1 }}
192
+ accessibilityElementsHidden
193
+ testID="meso-card-accent"
194
+ >
195
+ {ACCENT_STOPS.map((color) => (
196
+ <View key={color} style={{ flex: 1, backgroundColor: color }} />
197
+ ))}
198
+ </View>
199
+
200
+ {isExpandable ? (
201
+ <Pressable
202
+ onPress={onToggle}
203
+ accessibilityRole="button"
204
+ accessibilityLabel={`${name}, ${goal}, ${weekRange}`}
205
+ aria-expanded={expanded}
206
+ testID="meso-card-toggle"
207
+ >
208
+ {header}
209
+ </Pressable>
210
+ ) : (
211
+ <View
212
+ accessibilityLabel={`${name}, ${goal}, ${weekRange}`}
213
+ testID="meso-card-static"
214
+ >
215
+ {header}
216
+ </View>
217
+ )}
218
+
219
+ {expanded && weeks.length > 0 && (
220
+ <View
221
+ style={{ borderTopWidth: 1, borderTopColor: BORDER_DEFAULT }}
222
+ testID="meso-card-weeks"
223
+ >
224
+ {weeks.map((week) => (
225
+ <WeekRow
226
+ key={week.weekNumber}
227
+ {...week}
228
+ totalWeeks={week.totalWeeks ?? totalWeeks}
229
+ isCurrent={week.isCurrent ?? week.weekNumber === currentWeek}
230
+ />
231
+ ))}
232
+ </View>
233
+ )}
234
+ </Card>
235
+ </Animated.View>
236
+ )
237
+ }
@@ -0,0 +1,85 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View } from 'react-native'
4
+ import { MesoProgressBar, type Meso } from './MesoProgressBar'
5
+
6
+ const meta: Meta<typeof MesoProgressBar> = {
7
+ title: 'Custom/Workout/MesoProgressBar',
8
+ component: MesoProgressBar,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ activeMesoId: {
12
+ control: 'text',
13
+ description: 'Id of the highlighted meso, synced with MesoCard selection',
14
+ },
15
+ },
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof MesoProgressBar>
20
+
21
+ const sampleMesos: Meso[] = [
22
+ { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
23
+ { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
24
+ { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
25
+ { id: 'm4', name: 'Deload', weekCount: 1, status: 'upcoming' },
26
+ ]
27
+
28
+ export const Default: Story = {
29
+ args: {
30
+ mesos: sampleMesos,
31
+ activeMesoId: null,
32
+ onMesoPress: () => {},
33
+ },
34
+ render: (args) => (
35
+ <View style={{ paddingVertical: 24, width: 480 }}>
36
+ <MesoProgressBar {...args} />
37
+ </View>
38
+ ),
39
+ }
40
+
41
+ export const ActiveCurrent: Story = {
42
+ args: {
43
+ mesos: sampleMesos,
44
+ activeMesoId: 'm2',
45
+ onMesoPress: () => {},
46
+ },
47
+ render: (args) => (
48
+ <View style={{ paddingVertical: 24, width: 480 }}>
49
+ <MesoProgressBar {...args} />
50
+ </View>
51
+ ),
52
+ }
53
+
54
+ export const FirstWeekProgress: Story = {
55
+ args: {
56
+ mesos: [
57
+ { id: 'a', name: 'Base', weekCount: 5, status: 'current', currentWeek: 1 },
58
+ { id: 'b', name: 'Build', weekCount: 4, status: 'upcoming' },
59
+ ],
60
+ activeMesoId: 'a',
61
+ onMesoPress: () => {},
62
+ },
63
+ render: (args) => (
64
+ <View style={{ paddingVertical: 24, width: 480 }}>
65
+ <MesoProgressBar {...args} />
66
+ </View>
67
+ ),
68
+ }
69
+
70
+ function InteractiveDemo() {
71
+ const [activeMesoId, setActiveMesoId] = useState<string | null>('m2')
72
+ return (
73
+ <View style={{ paddingVertical: 24, width: 480 }}>
74
+ <MesoProgressBar
75
+ mesos={sampleMesos}
76
+ activeMesoId={activeMesoId}
77
+ onMesoPress={setActiveMesoId}
78
+ />
79
+ </View>
80
+ )
81
+ }
82
+
83
+ export const Interactive: Story = {
84
+ render: () => <InteractiveDemo />,
85
+ }