@titan-design/react-ui 0.2.6 → 0.2.7

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 (157) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/package.json +16 -5
  23. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  24. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  25. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  26. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  27. package/src/components/custom/Gauge/index.ts +2 -0
  28. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  29. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  30. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  31. package/src/components/custom/Scatter/index.ts +2 -0
  32. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  33. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  34. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  35. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  36. package/src/components/custom/Treemap/index.ts +2 -0
  37. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  38. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  39. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  40. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  41. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  42. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  43. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  46. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  47. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  48. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  49. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  50. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  51. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  52. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  54. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  55. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  56. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  57. package/src/components/custom/Workout/InputBar.tsx +163 -0
  58. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  59. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  60. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  61. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  62. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  63. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  64. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  65. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  66. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  67. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  68. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  69. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  70. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  73. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  76. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  77. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  78. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  79. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  80. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  81. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  82. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  83. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  84. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  85. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  86. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  87. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  88. package/src/components/custom/Workout/SetRow.tsx +253 -0
  89. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  90. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  91. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  92. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  93. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  94. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  95. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  98. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  99. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  100. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  101. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  102. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  103. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  104. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  105. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  106. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  107. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  108. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  109. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  110. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  111. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  112. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  113. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  114. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  115. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  116. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  117. package/src/components/custom/Workout/index.ts +113 -0
  118. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  119. package/src/components/custom/index.ts +3 -0
  120. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  121. package/src/components/custom/stepper/Stepper.tsx +2 -2
  122. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  123. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  124. package/src/components/ui/card/Card.tsx +1 -1
  125. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  126. package/src/components/ui/collapse/Collapse.tsx +1 -1
  127. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  128. package/src/components/ui/drawer/Drawer.tsx +3 -3
  129. package/src/components/ui/form-field/FormField.tsx +1 -1
  130. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  131. package/src/components/ui/menu/Menu.tsx +2 -2
  132. package/src/components/ui/pill/Pill.tsx +1 -1
  133. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  134. package/src/components/ui/popover/Popover.tsx +1 -1
  135. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  136. package/src/components/ui/section/Section.stories.tsx +4 -4
  137. package/src/components/ui/select/Select.tsx +1 -1
  138. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  139. package/src/components/ui/tabs/Tabs.tsx +2 -2
  140. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  141. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  142. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  143. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  144. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  145. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  146. package/src/stories/PresetShowcase.stories.tsx +15 -15
  147. package/src/theme/index.ts +2 -1
  148. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  149. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  150. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  151. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  152. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  153. package/src/theme/manifest/index.ts +11 -0
  154. package/src/theme/tokens-css.test.ts +28 -0
  155. package/src/theme/tokens-css.ts +34 -0
  156. package/src/theme/workout-tokens.ts +61 -0
  157. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,478 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import {
4
+ View,
5
+ Text,
6
+ Pressable,
7
+ ScrollView,
8
+ Animated,
9
+ Easing,
10
+ type ViewProps,
11
+ type ViewStyle,
12
+ } from 'react-native'
13
+ import { Badge, type BadgeColor } from '../../ui/badge'
14
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
15
+ import { Sparkline } from './Sparkline'
16
+ import {
17
+ MuscleGroup,
18
+ VOLUME_STATUS_LABELS,
19
+ type VolumeLandmarks,
20
+ type VolumeStatus,
21
+ } from './muscleTaxonomy'
22
+
23
+ const SURFACE_ELEVATED = WORKOUT_TOKENS.surface.elevated
24
+ const SURFACE_RAISED = WORKOUT_TOKENS.surface.raised
25
+ const BORDER_STRONG = WORKOUT_TOKENS.border.strong
26
+ const BORDER_DEFAULT = WORKOUT_TOKENS.border.default
27
+ const BRAND_PRIMARY = '#FF7900'
28
+ const TEXT_PRIMARY = '#F3F4F6'
29
+ const TEXT_SECONDARY = '#9CA3AF'
30
+ const TEXT_TERTIARY = '#6B7280'
31
+
32
+ /** Volume track gradient: status-info (blue) -> result-improve (teal) -> status-error (red). */
33
+ const VOLUME_GRADIENT = 'linear-gradient(90deg, #2196F3 0%, #14B8A6 50%, #D14343 100%)'
34
+
35
+ /** Sheet slides up from this offset (px) and the backdrop fades to this opacity. */
36
+ const SLIDE_OFFSET = 400
37
+ const BACKDROP_OPACITY = 0.4
38
+
39
+ /** Volume status -> Badge color scheme. */
40
+ const STATUS_BADGE_COLOR: Record<VolumeStatus, BadgeColor> = {
41
+ under: 'info',
42
+ maintenance: 'warning',
43
+ productive: 'success',
44
+ over: 'error',
45
+ }
46
+
47
+ export interface ContributingExercise {
48
+ /** Exercise name. */
49
+ name: string
50
+ /** Working sets contributed this week. */
51
+ sets: number
52
+ /** Effective-set multiplier toward this muscle (0-1). */
53
+ contributionWeight: number
54
+ }
55
+
56
+ export interface UpcomingExercise {
57
+ /** Exercise name. */
58
+ name: string
59
+ /** Workout the exercise belongs to. */
60
+ workoutName: string
61
+ /** Planned sets. */
62
+ sets: number
63
+ }
64
+
65
+ export interface BodyMapDetailPanelProps extends ViewProps {
66
+ /** Muscle group whose volume detail is shown. */
67
+ muscleGroup: MuscleGroup
68
+ /** Human-readable muscle name for the title and a11y label. */
69
+ displayName: string
70
+ /** Weekly effective sets logged for this muscle. */
71
+ weeklySets: number
72
+ /** Volume landmarks (weekly working sets). */
73
+ landmarks: VolumeLandmarks
74
+ /** Weekly-volume status relative to the landmarks. */
75
+ volumeStatus: VolumeStatus
76
+ /** Last trained label, e.g. "2 days ago". */
77
+ lastTrained?: string
78
+ /** Volume sparkline data (last 4-8 weeks). */
79
+ weeklyHistory?: number[]
80
+ /** Exercises contributing to this muscle's volume. */
81
+ contributingExercises?: ContributingExercise[]
82
+ /** Upcoming exercises targeting this muscle. */
83
+ upcomingExercises?: UpcomingExercise[]
84
+ /** Whether the bottom sheet is visible. */
85
+ isOpen?: boolean
86
+ /** Alias for `isOpen` (spec wording: "isOpen/visible"). `isOpen` wins when both are set. */
87
+ visible?: boolean
88
+ /** Dismiss the panel (backdrop, handle, or close button). */
89
+ onClose: () => void
90
+ /** Navigate to a filtered exercise list. */
91
+ onViewExercises?: () => void
92
+ }
93
+
94
+ function clamp01(value: number): number {
95
+ if (value < 0) return 0
96
+ if (value > 1) return 1
97
+ return value
98
+ }
99
+
100
+ /** Marker position (0-1) of current sets between MEV (left) and MRV (right). */
101
+ function markerFraction(weeklySets: number, { mev, mrv }: VolumeLandmarks): number {
102
+ const span = mrv - mev
103
+ if (span <= 0) return weeklySets >= mrv ? 1 : 0
104
+ return clamp01((weeklySets - mev) / span)
105
+ }
106
+
107
+ /**
108
+ * Slide-up bottom-sheet panel with detailed weekly-volume info for a tapped
109
+ * muscle group: a MEV|current|MRV gradient progress bar, the big weekly set
110
+ * count against MRV, an optional volume sparkline, and the contributing /
111
+ * upcoming exercise lists. Composes the titan `Badge` and Workout `Sparkline`.
112
+ *
113
+ * Controlled via `isOpen` (or the `visible` alias); renders nothing when closed
114
+ * and animates in on open — sheet translateY 400->0 (400ms ease-out) and a
115
+ * backdrop fade 0->0.4 (RN Animated, useNativeDriver:false). Backdrop, handle,
116
+ * and the header close button all call `onClose`.
117
+ *
118
+ * @example
119
+ * <BodyMapDetailPanel
120
+ * muscleGroup={MuscleGroup.CHEST}
121
+ * displayName="Chest"
122
+ * weeklySets={14}
123
+ * landmarks={{ mev: 8, mav: 14, mrv: 20 }}
124
+ * volumeStatus="productive"
125
+ * lastTrained="2 days ago"
126
+ * weeklyHistory={[8, 10, 12, 14]}
127
+ * isOpen={open}
128
+ * onClose={() => setOpen(false)}
129
+ * />
130
+ */
131
+ export function BodyMapDetailPanel({
132
+ muscleGroup,
133
+ displayName,
134
+ weeklySets,
135
+ landmarks,
136
+ volumeStatus,
137
+ lastTrained,
138
+ weeklyHistory,
139
+ contributingExercises,
140
+ upcomingExercises,
141
+ isOpen,
142
+ visible,
143
+ onClose,
144
+ onViewExercises,
145
+ ...props
146
+ }: BodyMapDetailPanelProps) {
147
+ const open = isOpen ?? visible ?? false
148
+
149
+ const [translateY] = useState(() => new Animated.Value(SLIDE_OFFSET))
150
+ const [backdrop] = useState(() => new Animated.Value(0))
151
+
152
+ useEffect(() => {
153
+ if (!open) return
154
+ const animation = Animated.parallel([
155
+ Animated.timing(translateY, {
156
+ toValue: 0,
157
+ duration: 400,
158
+ easing: Easing.out(Easing.ease),
159
+ useNativeDriver: false,
160
+ }),
161
+ Animated.timing(backdrop, {
162
+ toValue: BACKDROP_OPACITY,
163
+ duration: 300,
164
+ easing: Easing.out(Easing.ease),
165
+ useNativeDriver: false,
166
+ }),
167
+ ])
168
+ animation.start()
169
+ return () => animation.stop()
170
+ }, [open, translateY, backdrop])
171
+
172
+ if (!open) return null
173
+
174
+ const dialogLabel = `${displayName} volume details`
175
+ const markerLeft = markerFraction(weeklySets, landmarks) * 100
176
+ const hasContributing = (contributingExercises?.length ?? 0) > 0
177
+ const hasUpcoming = (upcomingExercises?.length ?? 0) > 0
178
+
179
+ return (
180
+ <View
181
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'flex-end' }}
182
+ testID="body-map-detail-panel-root"
183
+ >
184
+ <Animated.View
185
+ style={{
186
+ position: 'absolute',
187
+ top: 0,
188
+ left: 0,
189
+ right: 0,
190
+ bottom: 0,
191
+ backgroundColor: '#000000',
192
+ opacity: backdrop,
193
+ }}
194
+ testID="body-map-detail-panel-backdrop-anim"
195
+ >
196
+ <Pressable
197
+ onPress={onClose}
198
+ accessibilityRole="button"
199
+ accessibilityLabel={`Close ${displayName} details`}
200
+ style={{ flex: 1 }}
201
+ testID="body-map-detail-panel-backdrop"
202
+ />
203
+ </Animated.View>
204
+
205
+ <Animated.View
206
+ accessibilityRole={'dialog' as ViewProps['accessibilityRole']}
207
+ accessibilityLabel={dialogLabel}
208
+ aria-label={dialogLabel}
209
+ style={{
210
+ backgroundColor: SURFACE_ELEVATED,
211
+ borderTopLeftRadius: 16,
212
+ borderTopRightRadius: 16,
213
+ maxHeight: '88%',
214
+ transform: [{ translateY }],
215
+ }}
216
+ testID="body-map-detail-panel"
217
+ {...props}
218
+ >
219
+ <Pressable
220
+ onPress={onClose}
221
+ accessibilityRole="button"
222
+ accessibilityLabel={`Close ${displayName} details`}
223
+ hitSlop={12}
224
+ style={{ alignSelf: 'center', paddingVertical: 8 }}
225
+ testID="body-map-detail-panel-handle"
226
+ >
227
+ <View
228
+ style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: BORDER_STRONG }}
229
+ accessibilityElementsHidden
230
+ />
231
+ </Pressable>
232
+
233
+ <ScrollView
234
+ style={{ paddingHorizontal: 16 }}
235
+ contentContainerStyle={{ paddingBottom: 20 }}
236
+ testID="body-map-detail-panel-scroll"
237
+ >
238
+ <View
239
+ className="flex-row items-center justify-between"
240
+ style={{ gap: 8, paddingTop: 4, paddingBottom: 4 }}
241
+ testID="body-map-detail-panel-header"
242
+ >
243
+ <View className="flex-row items-center" style={{ gap: 8, flexShrink: 1 }}>
244
+ <Text
245
+ accessibilityRole="header"
246
+ style={{
247
+ fontSize: 16,
248
+ fontFamily: '"Space Grotesk", sans-serif',
249
+ fontWeight: '700',
250
+ color: TEXT_PRIMARY,
251
+ }}
252
+ testID="body-map-detail-panel-title"
253
+ >
254
+ {displayName}
255
+ </Text>
256
+ <Badge
257
+ variant="subtle"
258
+ color={STATUS_BADGE_COLOR[volumeStatus]}
259
+ size="sm"
260
+ testID="body-map-detail-panel-status-badge"
261
+ >
262
+ {VOLUME_STATUS_LABELS[volumeStatus]}
263
+ </Badge>
264
+ </View>
265
+ <Pressable
266
+ onPress={onClose}
267
+ accessibilityRole="button"
268
+ accessibilityLabel={`Close ${displayName} details`}
269
+ hitSlop={8}
270
+ style={{ padding: 4, margin: -4 }}
271
+ testID="body-map-detail-panel-close"
272
+ >
273
+ <Text style={{ fontSize: 22, lineHeight: 22, color: TEXT_SECONDARY }}>{'×'}</Text>
274
+ </Pressable>
275
+ </View>
276
+
277
+ {lastTrained != null && (
278
+ <Text
279
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
280
+ testID="body-map-detail-panel-last-trained"
281
+ >
282
+ {`Last trained ${lastTrained}`}
283
+ </Text>
284
+ )}
285
+
286
+ <View style={{ marginTop: 16 }} testID="body-map-detail-panel-volume">
287
+ <View
288
+ className="flex-row items-center justify-between"
289
+ style={{ marginBottom: 6 }}
290
+ accessibilityElementsHidden
291
+ >
292
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_TERTIARY }}>
293
+ {`MEV ${landmarks.mev}`}
294
+ </Text>
295
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_TERTIARY }}>
296
+ {`MRV ${landmarks.mrv}`}
297
+ </Text>
298
+ </View>
299
+ <View
300
+ style={{ height: 14, justifyContent: 'center' }}
301
+ accessibilityRole="progressbar"
302
+ accessibilityValue={{ min: landmarks.mev, max: landmarks.mrv, now: weeklySets }}
303
+ accessibilityLabel={`${weeklySets} weekly sets, between MEV ${landmarks.mev} and MRV ${landmarks.mrv}`}
304
+ aria-valuenow={weeklySets}
305
+ aria-valuemin={landmarks.mev}
306
+ aria-valuemax={landmarks.mrv}
307
+ testID="body-map-detail-panel-volume-bar"
308
+ >
309
+ <View
310
+ style={{ height: 8, borderRadius: 4, backgroundImage: VOLUME_GRADIENT } as ViewStyle}
311
+ accessibilityElementsHidden
312
+ />
313
+ <View
314
+ style={{
315
+ position: 'absolute',
316
+ left: `${markerLeft}%`,
317
+ marginLeft: -7,
318
+ width: 14,
319
+ height: 14,
320
+ borderRadius: 7,
321
+ borderWidth: 2,
322
+ borderColor: TEXT_PRIMARY,
323
+ backgroundColor: SURFACE_ELEVATED,
324
+ boxShadow: '0 0 4px rgba(0,0,0,0.5)',
325
+ }}
326
+ accessibilityElementsHidden
327
+ testID="body-map-detail-panel-volume-marker"
328
+ />
329
+ </View>
330
+ </View>
331
+
332
+ <View className="flex-row items-baseline" style={{ marginTop: 14, gap: 4 }}>
333
+ <Text
334
+ style={{
335
+ fontSize: 24,
336
+ fontFamily: '"Space Grotesk", sans-serif',
337
+ fontWeight: '700',
338
+ color: TEXT_PRIMARY,
339
+ }}
340
+ testID="body-map-detail-panel-set-count"
341
+ >
342
+ {weeklySets}
343
+ </Text>
344
+ <Text
345
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
346
+ testID="body-map-detail-panel-mrv-suffix"
347
+ >
348
+ {`/ ${landmarks.mrv} MRV`}
349
+ </Text>
350
+ </View>
351
+
352
+ {weeklyHistory != null && weeklyHistory.length > 0 && (
353
+ <View style={{ marginTop: 14 }} testID="body-map-detail-panel-sparkline">
354
+ <Text
355
+ style={{
356
+ fontSize: 10,
357
+ fontFamily: 'Inter, sans-serif',
358
+ fontWeight: '600',
359
+ color: TEXT_TERTIARY,
360
+ marginBottom: 6,
361
+ }}
362
+ accessibilityElementsHidden
363
+ >
364
+ {'VOLUME TREND'}
365
+ </Text>
366
+ <Sparkline data={weeklyHistory} width={80} height={30} highlightLast />
367
+ </View>
368
+ )}
369
+
370
+ {hasContributing && (
371
+ <View style={{ marginTop: 16 }} testID="body-map-detail-panel-contributing">
372
+ <Text
373
+ style={{
374
+ fontSize: 10,
375
+ fontFamily: 'Inter, sans-serif',
376
+ fontWeight: '600',
377
+ color: TEXT_TERTIARY,
378
+ marginBottom: 8,
379
+ }}
380
+ >
381
+ {'CONTRIBUTING EXERCISES'}
382
+ </Text>
383
+ {contributingExercises!.map((exercise, index) => (
384
+ <View
385
+ key={`${exercise.name}-${index}`}
386
+ className="flex-row items-center justify-between"
387
+ style={{
388
+ gap: 10,
389
+ paddingVertical: 8,
390
+ paddingHorizontal: 12,
391
+ marginBottom: 6,
392
+ borderRadius: 8,
393
+ backgroundColor: SURFACE_RAISED,
394
+ borderWidth: 1,
395
+ borderColor: BORDER_DEFAULT,
396
+ }}
397
+ testID={`body-map-detail-panel-contributing-${index}`}
398
+ >
399
+ <Text
400
+ style={{ flex: 1, fontSize: 13, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_PRIMARY }}
401
+ testID={`body-map-detail-panel-contributing-${index}-name`}
402
+ >
403
+ {exercise.name}
404
+ </Text>
405
+ <Text
406
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
407
+ testID={`body-map-detail-panel-contributing-${index}-detail`}
408
+ >
409
+ {`${exercise.sets} sets · ${Math.round(exercise.contributionWeight * 100)}%`}
410
+ </Text>
411
+ </View>
412
+ ))}
413
+ </View>
414
+ )}
415
+
416
+ {hasUpcoming && (
417
+ <View style={{ marginTop: 16 }} testID="body-map-detail-panel-upcoming">
418
+ <Text
419
+ style={{
420
+ fontSize: 10,
421
+ fontFamily: 'Inter, sans-serif',
422
+ fontWeight: '600',
423
+ color: TEXT_TERTIARY,
424
+ marginBottom: 8,
425
+ }}
426
+ >
427
+ {'UPCOMING'}
428
+ </Text>
429
+ {upcomingExercises!.map((exercise, index) => (
430
+ <View
431
+ key={`${exercise.name}-${index}`}
432
+ className="flex-row items-center justify-between"
433
+ style={{ gap: 10, paddingVertical: 6 }}
434
+ testID={`body-map-detail-panel-upcoming-${index}`}
435
+ >
436
+ <Text
437
+ style={{ flex: 1, fontSize: 13, fontFamily: 'Inter, sans-serif', fontWeight: '600', color: TEXT_PRIMARY }}
438
+ testID={`body-map-detail-panel-upcoming-${index}-name`}
439
+ >
440
+ {exercise.name}
441
+ </Text>
442
+ <Text
443
+ style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}
444
+ testID={`body-map-detail-panel-upcoming-${index}-detail`}
445
+ >
446
+ {`${exercise.workoutName} · ${exercise.sets} sets`}
447
+ </Text>
448
+ </View>
449
+ ))}
450
+ </View>
451
+ )}
452
+
453
+ {onViewExercises != null && (
454
+ <Pressable
455
+ onPress={onViewExercises}
456
+ accessibilityRole="button"
457
+ accessibilityLabel={`View ${displayName} exercises`}
458
+ style={{
459
+ marginTop: 16,
460
+ paddingVertical: 10,
461
+ borderRadius: 8,
462
+ alignItems: 'center',
463
+ backgroundColor: 'rgba(255,121,0,0.12)',
464
+ borderWidth: 1,
465
+ borderColor: 'rgba(255,121,0,0.3)',
466
+ }}
467
+ testID="body-map-detail-panel-view-exercises"
468
+ >
469
+ <Text style={{ fontSize: 13, fontFamily: 'Inter, sans-serif', fontWeight: '700', color: BRAND_PRIMARY }}>
470
+ {'View exercises'}
471
+ </Text>
472
+ </Pressable>
473
+ )}
474
+ </ScrollView>
475
+ </Animated.View>
476
+ </View>
477
+ )
478
+ }
@@ -0,0 +1,125 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View, Text } from 'react-native'
4
+ import { CapacityBandChart } from './CapacityBandChart'
5
+ import type {
6
+ CapacityBandDataPoint,
7
+ CapacityBandProjection,
8
+ WorkoutDot,
9
+ } from './CapacityBandChart'
10
+
11
+ const band: CapacityBandDataPoint[] = [
12
+ { date: '2026-06-01', bandLow: 40, bandHigh: 70 },
13
+ { date: '2026-06-05', bandLow: 44, bandHigh: 74 },
14
+ { date: '2026-06-09', bandLow: 50, bandHigh: 80 },
15
+ { date: '2026-06-13', bandLow: 54, bandHigh: 86 },
16
+ { date: '2026-06-17', bandLow: 58, bandHigh: 90 },
17
+ { date: '2026-06-21', bandLow: 60, bandHigh: 94 },
18
+ { date: '2026-06-25', bandLow: 62, bandHigh: 96 },
19
+ ]
20
+
21
+ const workouts: WorkoutDot[] = [
22
+ { date: '2026-06-02', load: 58, status: 'within' },
23
+ { date: '2026-06-06', load: 80, status: 'above' },
24
+ { date: '2026-06-10', load: 66, status: 'within' },
25
+ { date: '2026-06-14', load: 48, status: 'below' },
26
+ { date: '2026-06-18', load: 76, status: 'within' },
27
+ { date: '2026-06-23', load: 100, status: 'above' },
28
+ ]
29
+
30
+ const projection: CapacityBandProjection = {
31
+ withTraining: [
32
+ { date: '2026-06-27', bandLow: 64, bandHigh: 99 },
33
+ { date: '2026-06-29', bandLow: 67, bandHigh: 104 },
34
+ ],
35
+ withRest: [
36
+ { date: '2026-06-27', bandLow: 56, bandHigh: 88 },
37
+ { date: '2026-06-29', bandLow: 48, bandHigh: 78 },
38
+ ],
39
+ }
40
+
41
+ const meta: Meta<typeof CapacityBandChart> = {
42
+ title: 'Custom/Workout/CapacityBandChart',
43
+ component: CapacityBandChart,
44
+ tags: ['autodocs'],
45
+ argTypes: {
46
+ width: { control: { type: 'range', min: 240, max: 480, step: 10 }, description: 'Chart width in px' },
47
+ height: { control: { type: 'range', min: 120, max: 280, step: 10 }, description: 'Chart height in px' },
48
+ band: { description: 'Capacity band shape over time' },
49
+ workouts: { description: 'Workout sessions plotted as load dots' },
50
+ projection: { description: 'Optional dashed forward projection (training vs rest)' },
51
+ onWorkoutPress: { description: 'Called when a workout dot is tapped' },
52
+ },
53
+ }
54
+
55
+ export default meta
56
+ type Story = StoryObj<typeof CapacityBandChart>
57
+
58
+ export const Default: Story = {
59
+ args: {
60
+ band,
61
+ workouts,
62
+ projection,
63
+ width: 340,
64
+ height: 200,
65
+ },
66
+ }
67
+
68
+ export const WithoutProjection: Story = {
69
+ args: {
70
+ ...Default.args,
71
+ projection: undefined,
72
+ },
73
+ }
74
+
75
+ export const Compact: Story = {
76
+ args: {
77
+ ...Default.args,
78
+ width: 260,
79
+ height: 140,
80
+ },
81
+ }
82
+
83
+ export const SparseData: Story = {
84
+ args: {
85
+ band: band.slice(0, 3),
86
+ workouts: workouts.slice(0, 2),
87
+ projection: undefined,
88
+ width: 300,
89
+ height: 180,
90
+ },
91
+ }
92
+
93
+ export const Empty: Story = {
94
+ args: {
95
+ band: [],
96
+ workouts: [],
97
+ width: 300,
98
+ height: 180,
99
+ },
100
+ }
101
+
102
+ function InteractiveCapacityBandChart() {
103
+ const [selected, setSelected] = useState<WorkoutDot | null>(null)
104
+ return (
105
+ <View style={{ maxWidth: 380, padding: 16, gap: 10 }}>
106
+ <CapacityBandChart
107
+ band={band}
108
+ workouts={workouts}
109
+ projection={projection}
110
+ width={340}
111
+ height={200}
112
+ onWorkoutPress={setSelected}
113
+ />
114
+ <Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: '#9CA3AF' }}>
115
+ {selected
116
+ ? `Selected ${selected.date}: load ${selected.load} (${selected.status})`
117
+ : 'Tap a workout dot to inspect the session.'}
118
+ </Text>
119
+ </View>
120
+ )
121
+ }
122
+
123
+ export const Interactive: Story = {
124
+ render: () => <InteractiveCapacityBandChart />,
125
+ }