@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,442 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState } from 'react'
3
+ import { View, Text, Pressable } from 'react-native'
4
+ import { VelocityStrip } from './VelocityStrip'
5
+ import { PlaceholderStrip } from './PlaceholderStrip'
6
+ import { WeightBadge } from './WeightBadge'
7
+ import { PrBadge } from './PrBadge'
8
+ import { TempoDisplay } from './TempoDisplay'
9
+ import { SetRow, type SetRowProps } from './SetRow'
10
+ import { roundWeight } from '../../../utils/workout-format'
11
+
12
+ export type ExerciseCardState = 'collapsed' | 'expanded' | 'upcoming'
13
+
14
+ export interface ExerciseCardProps {
15
+ name: string
16
+ state: ExerciseCardState
17
+ onToggle: () => void
18
+ onNavigateDetail?: () => void
19
+ summary?: {
20
+ sets: number
21
+ reps: number | string
22
+ weight: number
23
+ unit: 'lbs' | 'kg'
24
+ }
25
+ e1rm?: { value: number; unit: 'lbs' | 'kg' }
26
+ isPR?: boolean
27
+ setVelocities?: number[][]
28
+ totalPlannedSets?: number
29
+ sets?: SetRowProps[]
30
+ tempo?: [number, number, number, number]
31
+ prescription?: string
32
+ previousBest?: string
33
+ supersetPosition?: 'first' | 'last' | 'middle' | null
34
+ supersetColor?: string
35
+ }
36
+
37
+ const COLORS = {
38
+ // Theme-aware tokens: text foregrounds and this card's own surfaces/borders
39
+ // must flip together so text stays readable in both light and dark. The
40
+ // expanded/pressed surfaces are self-owned here (not from a themed Card), so
41
+ // hardcoding dark values would leave dark-on-dark once the text flips.
42
+ // react-native-web passes the CSS var through to the inline style.
43
+ textPrimary: 'var(--color-text-primary)',
44
+ textSecondary: 'var(--color-text-secondary)',
45
+ textTertiary: 'var(--color-text-tertiary)',
46
+ brandPrimary: '#FF7900',
47
+ surfaceRaised: 'var(--color-surface-raised)',
48
+ surfaceElevated: 'var(--color-surface-elevated)',
49
+ borderDefault: 'var(--color-border-default)',
50
+ }
51
+
52
+ /** Column headers; the weight column reflects the card's unit (LBS / KG). */
53
+ function buildColumnHeaders(unit: 'lbs' | 'kg'): string[] {
54
+ return ['SET', 'PREV', 'REPS', unit.toUpperCase(), 'RPE']
55
+ }
56
+
57
+ function getSupersetBorderRadius(
58
+ position: ExerciseCardProps['supersetPosition'],
59
+ ): Record<string, number> {
60
+ switch (position) {
61
+ case 'first':
62
+ return {
63
+ borderTopLeftRadius: 12,
64
+ borderTopRightRadius: 12,
65
+ borderBottomLeftRadius: 8,
66
+ borderBottomRightRadius: 8,
67
+ }
68
+ case 'last':
69
+ return {
70
+ borderTopLeftRadius: 8,
71
+ borderTopRightRadius: 8,
72
+ borderBottomLeftRadius: 12,
73
+ borderBottomRightRadius: 12,
74
+ }
75
+ case 'middle':
76
+ return {
77
+ borderTopLeftRadius: 8,
78
+ borderTopRightRadius: 8,
79
+ borderBottomLeftRadius: 8,
80
+ borderBottomRightRadius: 8,
81
+ }
82
+ default:
83
+ return {
84
+ borderTopLeftRadius: 12,
85
+ borderTopRightRadius: 12,
86
+ borderBottomLeftRadius: 12,
87
+ borderBottomRightRadius: 12,
88
+ }
89
+ }
90
+ }
91
+
92
+ function getSupersetMargin(
93
+ position: ExerciseCardProps['supersetPosition'],
94
+ ): Record<string, number> {
95
+ if (position === 'first' || position === 'middle') {
96
+ return { marginBottom: 2 }
97
+ }
98
+ return {}
99
+ }
100
+
101
+ function formatSummary(summary: ExerciseCardProps['summary']): string {
102
+ if (!summary) return ''
103
+ return `${summary.sets}\u00D7${summary.reps} @ ${roundWeight(summary.weight)} ${summary.unit}`
104
+ }
105
+
106
+ function formatAccessibilityLabel(
107
+ name: string,
108
+ summary: ExerciseCardProps['summary'],
109
+ prescription: ExerciseCardProps['prescription'],
110
+ ): string {
111
+ if (summary) return `${name}, ${formatSummary(summary)}`
112
+ if (prescription) return `${name}, ${prescription}`
113
+ return name
114
+ }
115
+
116
+ function CollapsedCard({
117
+ name,
118
+ onToggle,
119
+ summary,
120
+ e1rm,
121
+ isPR,
122
+ setVelocities,
123
+ totalPlannedSets,
124
+ supersetPosition,
125
+ }: ExerciseCardProps) {
126
+ const [pressed, setPressed] = useState(false)
127
+ const completedSets = setVelocities?.length ?? 0
128
+ const remaining = Math.max(0, (totalPlannedSets ?? 0) - completedSets)
129
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
130
+ const supersetMargin = getSupersetMargin(supersetPosition)
131
+
132
+ return (
133
+ <Pressable
134
+ onPress={onToggle}
135
+ onPressIn={() => setPressed(true)}
136
+ onPressOut={() => setPressed(false)}
137
+ accessibilityRole="button"
138
+ accessibilityLabel={formatAccessibilityLabel(name, summary, undefined)}
139
+ testID="exercise-card"
140
+ >
141
+ <View
142
+ style={{
143
+ padding: 12,
144
+ paddingHorizontal: 14,
145
+ cursor: 'pointer',
146
+ backgroundColor: pressed ? COLORS.surfaceRaised : 'transparent',
147
+ ...borderRadius,
148
+ ...supersetMargin,
149
+ }}
150
+ >
151
+ <View
152
+ className="flex-row items-center"
153
+ testID="exercise-card-header"
154
+ >
155
+ <Text
156
+ style={{
157
+ fontSize: 14,
158
+ fontFamily: '"Space Grotesk", sans-serif',
159
+ fontWeight: '700',
160
+ color: COLORS.textPrimary,
161
+ }}
162
+ testID="exercise-card-name"
163
+ >
164
+ {name}
165
+ </Text>
166
+ <View className="flex-1" />
167
+ {summary && (
168
+ <Text
169
+ style={{
170
+ fontSize: 12,
171
+ fontFamily: 'Inter, sans-serif',
172
+ color: COLORS.textSecondary,
173
+ marginRight: 8,
174
+ }}
175
+ testID="exercise-card-summary"
176
+ >
177
+ {formatSummary(summary)}
178
+ </Text>
179
+ )}
180
+ {e1rm && (
181
+ <WeightBadge
182
+ value={roundWeight(e1rm.value)}
183
+ unit={e1rm.unit}
184
+ size="sm"
185
+ showIcon={false}
186
+ testID="exercise-card-e1rm"
187
+ />
188
+ )}
189
+ {isPR && (
190
+ <View style={{ marginLeft: 4 }}>
191
+ <PrBadge type="e1rm" compact animate={false} />
192
+ </View>
193
+ )}
194
+ </View>
195
+
196
+ {(completedSets > 0 || remaining > 0) && (
197
+ <View
198
+ className="flex-row"
199
+ style={{ marginTop: 6, gap: 4 }}
200
+ testID="exercise-card-strips"
201
+ >
202
+ {setVelocities?.map((velocities, i) => (
203
+ <VelocityStrip
204
+ key={i}
205
+ velocities={velocities}
206
+ variant="mini"
207
+ testID={`exercise-card-velocity-strip-${i}`}
208
+ />
209
+ ))}
210
+ {Array.from({ length: remaining }, (_, i) => (
211
+ <PlaceholderStrip
212
+ key={`placeholder-${i}`}
213
+ testID={`exercise-card-placeholder-${i}`}
214
+ />
215
+ ))}
216
+ </View>
217
+ )}
218
+ </View>
219
+ </Pressable>
220
+ )
221
+ }
222
+
223
+ function ExpandedCard({
224
+ name,
225
+ onToggle,
226
+ summary,
227
+ e1rm,
228
+ isPR,
229
+ sets,
230
+ tempo,
231
+ supersetPosition,
232
+ }: ExerciseCardProps) {
233
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
234
+ const supersetMargin = getSupersetMargin(supersetPosition)
235
+ // Summary is the card-level authority for the weight column; fall back to the
236
+ // first set's unit, then lbs. (Mixed per-set units keep the card-level label.)
237
+ const unit = summary?.unit ?? sets?.[0]?.unit ?? 'lbs'
238
+ const columnHeaders = buildColumnHeaders(unit)
239
+
240
+ return (
241
+ <View
242
+ style={{
243
+ backgroundColor: COLORS.surfaceElevated,
244
+ borderWidth: 1,
245
+ borderColor: COLORS.borderDefault,
246
+ ...borderRadius,
247
+ ...supersetMargin,
248
+ }}
249
+ testID="exercise-card"
250
+ >
251
+ <Pressable
252
+ onPress={onToggle}
253
+ accessibilityRole="button"
254
+ accessibilityLabel={formatAccessibilityLabel(name, summary, undefined)}
255
+ aria-expanded={true}
256
+ testID="exercise-card-header"
257
+ >
258
+ <View
259
+ className="flex-row items-center"
260
+ style={{
261
+ padding: 12,
262
+ paddingHorizontal: 14,
263
+ paddingBottom: 10,
264
+ borderBottomWidth: 1,
265
+ borderBottomColor: COLORS.borderDefault,
266
+ }}
267
+ >
268
+ <Text
269
+ style={{
270
+ fontSize: 14,
271
+ fontFamily: '"Space Grotesk", sans-serif',
272
+ fontWeight: '700',
273
+ color: COLORS.textPrimary,
274
+ }}
275
+ testID="exercise-card-name"
276
+ >
277
+ {name}
278
+ </Text>
279
+ <View className="flex-1" />
280
+ {summary && (
281
+ <Text
282
+ style={{
283
+ fontSize: 12,
284
+ fontFamily: 'Inter, sans-serif',
285
+ color: COLORS.textSecondary,
286
+ marginRight: 8,
287
+ }}
288
+ testID="exercise-card-summary"
289
+ >
290
+ {formatSummary(summary)}
291
+ </Text>
292
+ )}
293
+ {e1rm && (
294
+ <WeightBadge
295
+ value={roundWeight(e1rm.value)}
296
+ unit={e1rm.unit}
297
+ size="sm"
298
+ showIcon={false}
299
+ testID="exercise-card-e1rm"
300
+ />
301
+ )}
302
+ {isPR && (
303
+ <View style={{ marginLeft: 4 }}>
304
+ <PrBadge type="e1rm" compact animate={false} />
305
+ </View>
306
+ )}
307
+ </View>
308
+ </Pressable>
309
+
310
+ {tempo && (
311
+ <View style={{ marginTop: 8, paddingHorizontal: 14 }} testID="exercise-card-tempo">
312
+ <TempoDisplay tempo={tempo} size="sm" />
313
+ </View>
314
+ )}
315
+
316
+ <View
317
+ className="flex-row"
318
+ style={{
319
+ paddingVertical: 8,
320
+ paddingHorizontal: 8,
321
+ paddingBottom: 4,
322
+ }}
323
+ testID="exercise-card-column-headers"
324
+ >
325
+ {columnHeaders.map((header, i) => {
326
+ const widths = [36, undefined, 44, 56, 36]
327
+ const width = widths[i]
328
+ const isFlex = width === undefined
329
+
330
+ return (
331
+ <View
332
+ key={header}
333
+ style={{
334
+ ...(isFlex ? {} : { width }),
335
+ alignItems: 'center',
336
+ justifyContent: 'center',
337
+ gap: 8,
338
+ }}
339
+ {...(isFlex ? { className: 'flex-1' } : {})}
340
+ >
341
+ <Text
342
+ style={{
343
+ fontSize: 10,
344
+ fontWeight: '600',
345
+ fontFamily: 'Inter, sans-serif',
346
+ textTransform: 'uppercase',
347
+ letterSpacing: 0.8,
348
+ color: COLORS.textTertiary,
349
+ }}
350
+ >
351
+ {header}
352
+ </Text>
353
+ </View>
354
+ )
355
+ })}
356
+ </View>
357
+
358
+ {sets && (
359
+ <View testID="exercise-card-sets">
360
+ {sets.map((setProps, i) => (
361
+ <SetRow key={i} {...setProps} />
362
+ ))}
363
+ </View>
364
+ )}
365
+ </View>
366
+ )
367
+ }
368
+
369
+ function UpcomingCard({
370
+ name,
371
+ prescription,
372
+ previousBest,
373
+ supersetPosition,
374
+ }: ExerciseCardProps) {
375
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
376
+ const supersetMargin = getSupersetMargin(supersetPosition)
377
+
378
+ return (
379
+ <View
380
+ style={{
381
+ opacity: 0.6,
382
+ padding: 12,
383
+ paddingHorizontal: 14,
384
+ ...borderRadius,
385
+ ...supersetMargin,
386
+ }}
387
+ accessibilityLabel={formatAccessibilityLabel(name, undefined, prescription)}
388
+ testID="exercise-card"
389
+ >
390
+ <View className="flex-row items-center">
391
+ <Text
392
+ style={{
393
+ fontSize: 14,
394
+ fontFamily: '"Space Grotesk", sans-serif',
395
+ fontWeight: '700',
396
+ color: COLORS.textPrimary,
397
+ }}
398
+ testID="exercise-card-name"
399
+ >
400
+ {name}
401
+ </Text>
402
+ {prescription && (
403
+ <Text
404
+ style={{
405
+ fontSize: 12,
406
+ fontFamily: 'Inter, sans-serif',
407
+ color: COLORS.textSecondary,
408
+ marginLeft: 8,
409
+ }}
410
+ testID="exercise-card-prescription"
411
+ >
412
+ {prescription}
413
+ </Text>
414
+ )}
415
+ <View className="flex-1" />
416
+ {previousBest && (
417
+ <Text
418
+ style={{
419
+ fontSize: 11,
420
+ fontFamily: 'Inter, sans-serif',
421
+ color: COLORS.textTertiary,
422
+ }}
423
+ testID="exercise-card-previous-best"
424
+ >
425
+ {previousBest}
426
+ </Text>
427
+ )}
428
+ </View>
429
+ </View>
430
+ )
431
+ }
432
+
433
+ export function ExerciseCard(props: ExerciseCardProps) {
434
+ switch (props.state) {
435
+ case 'collapsed':
436
+ return <CollapsedCard {...props} />
437
+ case 'expanded':
438
+ return <ExpandedCard {...props} />
439
+ case 'upcoming':
440
+ return <UpcomingCard {...props} />
441
+ }
442
+ }
@@ -0,0 +1,225 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import {
3
+ ExerciseDetailPage,
4
+ type ExerciseDetailEntry,
5
+ type ExerciseTrend,
6
+ type ExerciseVbt,
7
+ } from './ExerciseDetailPage'
8
+ import type { MesoStatusCardProps } from './MesoStatusCard'
9
+ import type { SetRowProps } from './SetRow'
10
+
11
+ const meso: MesoStatusCardProps = {
12
+ mesoName: 'Hypertrophy Block',
13
+ mesoSubtitle: 'Week 6 of 8 · Push/Pull/Legs',
14
+ statusBadge: { label: 'On Track', variant: 'success' },
15
+ metrics: [
16
+ { label: 'Prescribed', value: '4×8 @ RPE 8' },
17
+ { label: 'Actual', value: '4×8 @ 195 lbs' },
18
+ { label: 'Best e1RM', value: '248 lbs' },
19
+ { label: 'Sessions', value: '11' },
20
+ ],
21
+ gauges: [
22
+ { label: 'Intensity', level: 0.72, sublabel: 'Actual' },
23
+ { label: 'Volume', level: 0.6, sublabel: 'Weekly' },
24
+ ],
25
+ coaching: {
26
+ text: 'Bar speed is holding — add 5 lbs next session before the deload.',
27
+ highlights: ['add 5 lbs'],
28
+ },
29
+ nextTarget: { icon: '🎯', text: '200 lbs × 8 @ RPE 8' },
30
+ }
31
+
32
+ function historySets(weight: number, topRpe: number): SetRowProps[] {
33
+ return [1, 2, 3, 4].map((setNumber) => ({
34
+ mode: 'history' as const,
35
+ setNumber,
36
+ previous: { reps: 8, weight: weight - 5 },
37
+ reps: 8,
38
+ weight,
39
+ rpe: topRpe - (4 - setNumber) * 0.5,
40
+ unit: 'lbs' as const,
41
+ }))
42
+ }
43
+
44
+ const progression: ExerciseDetailEntry[] = [
45
+ {
46
+ id: 'w1',
47
+ title: 'Week 1',
48
+ summary: { sets: 4, reps: 8, weight: 175, unit: 'lbs' },
49
+ e1rm: { value: 222, unit: 'lbs' },
50
+ tempo: [3, 1, 1, 0],
51
+ sets: historySets(175, 7.5),
52
+ },
53
+ {
54
+ id: 'w2',
55
+ title: 'Week 2',
56
+ summary: { sets: 4, reps: 8, weight: 180, unit: 'lbs' },
57
+ e1rm: { value: 228, unit: 'lbs' },
58
+ tempo: [3, 1, 1, 0],
59
+ sets: historySets(180, 8),
60
+ },
61
+ {
62
+ id: 'w3',
63
+ title: 'Week 3',
64
+ summary: { sets: 4, reps: 8, weight: 185, unit: 'lbs' },
65
+ e1rm: { value: 235, unit: 'lbs' },
66
+ tempo: [3, 1, 1, 0],
67
+ sets: historySets(185, 8.5),
68
+ },
69
+ {
70
+ id: 'w4',
71
+ title: 'Week 4 · Deload',
72
+ summary: { sets: 3, reps: 6, weight: 165, unit: 'lbs' },
73
+ e1rm: { value: 205, unit: 'lbs' },
74
+ sets: historySets(165, 6.5).slice(0, 3),
75
+ },
76
+ {
77
+ id: 'w5',
78
+ title: 'Week 5',
79
+ summary: { sets: 4, reps: 8, weight: 190, unit: 'lbs' },
80
+ e1rm: { value: 241, unit: 'lbs' },
81
+ tempo: [3, 1, 1, 0],
82
+ sets: historySets(190, 8.5),
83
+ },
84
+ {
85
+ id: 'w6',
86
+ title: 'Week 6',
87
+ summary: { sets: 4, reps: 8, weight: 195, unit: 'lbs' },
88
+ e1rm: { value: 248, unit: 'lbs' },
89
+ isPR: true,
90
+ tempo: [3, 1, 1, 0],
91
+ sets: historySets(195, 9),
92
+ },
93
+ ]
94
+
95
+ const history: ExerciseDetailEntry[] = [
96
+ {
97
+ id: 's6',
98
+ title: 'Mon, Jun 16',
99
+ summary: { sets: 4, reps: 8, weight: 195, unit: 'lbs' },
100
+ e1rm: { value: 248, unit: 'lbs' },
101
+ isPR: true,
102
+ tempo: [3, 1, 1, 0],
103
+ sets: historySets(195, 9),
104
+ },
105
+ {
106
+ id: 's5',
107
+ title: 'Mon, Jun 9',
108
+ summary: { sets: 4, reps: 8, weight: 190, unit: 'lbs' },
109
+ e1rm: { value: 241, unit: 'lbs' },
110
+ sets: historySets(190, 8.5),
111
+ },
112
+ {
113
+ id: 's4',
114
+ title: 'Mon, Jun 2 · Deload',
115
+ summary: { sets: 3, reps: 6, weight: 165, unit: 'lbs' },
116
+ e1rm: { value: 205, unit: 'lbs' },
117
+ sets: historySets(165, 6.5).slice(0, 3),
118
+ },
119
+ {
120
+ id: 's3',
121
+ title: 'Mon, May 26',
122
+ summary: { sets: 4, reps: 8, weight: 185, unit: 'lbs' },
123
+ e1rm: { value: 235, unit: 'lbs' },
124
+ sets: historySets(185, 8.5),
125
+ },
126
+ {
127
+ id: 's2',
128
+ title: 'Mon, May 19',
129
+ summary: { sets: 4, reps: 8, weight: 180, unit: 'lbs' },
130
+ e1rm: { value: 228, unit: 'lbs' },
131
+ sets: historySets(180, 8),
132
+ },
133
+ {
134
+ id: 's1',
135
+ title: 'Mon, May 12',
136
+ summary: { sets: 4, reps: 8, weight: 175, unit: 'lbs' },
137
+ e1rm: { value: 222, unit: 'lbs' },
138
+ sets: historySets(175, 7.5),
139
+ },
140
+ ]
141
+
142
+ const trend: ExerciseTrend = {
143
+ data: [
144
+ { date: '2026-05-12', e1rm: 222 },
145
+ { date: '2026-05-19', e1rm: 228 },
146
+ { date: '2026-05-26', e1rm: 235 },
147
+ { date: '2026-06-02', e1rm: 231 },
148
+ { date: '2026-06-09', e1rm: 241 },
149
+ { date: '2026-06-16', e1rm: 248, isPR: true },
150
+ ],
151
+ projection: [
152
+ { date: '2026-06-16', e1rm: 248 },
153
+ { date: '2026-06-23', e1rm: 253 },
154
+ { date: '2026-06-30', e1rm: 258 },
155
+ ],
156
+ mesoBoundaries: [{ date: '2026-06-02', label: 'Deload' }],
157
+ }
158
+
159
+ const vbt: ExerciseVbt = {
160
+ band: [
161
+ { date: '2026-05-12', bandLow: 40, bandHigh: 70 },
162
+ { date: '2026-05-26', bandLow: 48, bandHigh: 78 },
163
+ { date: '2026-06-09', bandLow: 55, bandHigh: 86 },
164
+ { date: '2026-06-16', bandLow: 60, bandHigh: 92 },
165
+ ],
166
+ workouts: [
167
+ { date: '2026-05-12', load: 52, status: 'within' },
168
+ { date: '2026-05-26', load: 80, status: 'above' },
169
+ { date: '2026-06-09', load: 68, status: 'within' },
170
+ { date: '2026-06-16', load: 58, status: 'below' },
171
+ ],
172
+ projection: {
173
+ withTraining: [
174
+ { date: '2026-06-16', bandLow: 60, bandHigh: 92 },
175
+ { date: '2026-06-23', bandLow: 64, bandHigh: 98 },
176
+ ],
177
+ withRest: [
178
+ { date: '2026-06-16', bandLow: 60, bandHigh: 92 },
179
+ { date: '2026-06-23', bandLow: 54, bandHigh: 84 },
180
+ ],
181
+ },
182
+ sets: [
183
+ { label: 'Set 1', velocities: [0.72, 0.7, 0.68, 0.66, 0.63, 0.6, 0.57, 0.54] },
184
+ { label: 'Set 2', velocities: [0.7, 0.67, 0.64, 0.61, 0.58, 0.54, 0.5, 0.46] },
185
+ { label: 'Set 3', velocities: [0.68, 0.64, 0.6, 0.56, 0.52, 0.47, 0.42, 0.38] },
186
+ { label: 'Set 4', velocities: [0.65, 0.6, 0.55, 0.5, 0.44, 0.39, 0.34, 0.3] },
187
+ ],
188
+ }
189
+
190
+ const meta: Meta<typeof ExerciseDetailPage> = {
191
+ title: 'Custom/Workout/ExerciseDetailPage',
192
+ component: ExerciseDetailPage,
193
+ parameters: { layout: 'fullscreen' },
194
+ tags: ['autodocs'],
195
+ args: {
196
+ exercise: {
197
+ name: 'Barbell Bench Press',
198
+ subtitle: 'Chest · Barbell',
199
+ unit: 'lbs',
200
+ currentE1rm: 248,
201
+ },
202
+ meso,
203
+ trend,
204
+ progression,
205
+ history,
206
+ vbt,
207
+ },
208
+ }
209
+
210
+ export default meta
211
+ type Story = StoryObj<typeof ExerciseDetailPage>
212
+
213
+ export const Default: Story = {}
214
+
215
+ export const NoVbtData: Story = {
216
+ args: {
217
+ exercise: {
218
+ name: 'Goblet Squat',
219
+ subtitle: 'Quads · Dumbbell',
220
+ unit: 'lbs',
221
+ currentE1rm: 132,
222
+ },
223
+ vbt: { band: [], workouts: [], sets: [] },
224
+ },
225
+ }