@titan-design/react-ui 0.2.5 → 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 (158) 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/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,348 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import BodyHighlighter, { type ExtendedBodyPart, type Slug } from 'react-native-body-highlighter'
5
+ import { cn } from '../../../utils/cn'
6
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
7
+ import {
8
+ MuscleGroup,
9
+ SimpleMuscleGroup,
10
+ MUSCLE_TO_SVG_SLUGS,
11
+ MUSCLE_DISPLAY_NAMES,
12
+ SIMPLE_DISPLAY_NAMES,
13
+ DETAILED_TO_SIMPLE,
14
+ SLUG_TO_PRIMARY_MUSCLE,
15
+ VOLUME_STATUS_LABELS,
16
+ getHeatmapColor,
17
+ isMoreSevere,
18
+ type VolumeStatus,
19
+ } from './muscleTaxonomy'
20
+
21
+ /**
22
+ * react-native-body-highlighter is published as a CommonJS default export; the
23
+ * interop keeps it working whether the bundler unwraps the default or not.
24
+ */
25
+ const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter }).default ??
26
+ BodyHighlighter) as typeof BodyHighlighter
27
+
28
+ const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
29
+ const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
30
+ const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
31
+ const TEXT_PRIMARY = '#F3F4F6'
32
+ const TEXT_SECONDARY = '#9CA3AF'
33
+
34
+ export interface BodyMapData {
35
+ muscleGroup: MuscleGroup
36
+ /** Intensity 0-1 for heatmap color. */
37
+ intensity: number
38
+ /** Volume status for tooltip / heatmap token. */
39
+ volumeStatus: VolumeStatus
40
+ /** Weekly effective sets. */
41
+ weeklySets: number
42
+ }
43
+
44
+ export interface BodyMapProps extends ViewProps {
45
+ /** Muscle group intensity data. */
46
+ data: BodyMapData[]
47
+ /** Which view to show. */
48
+ view: 'front' | 'back'
49
+ /** Toggle between front/back. */
50
+ onViewChange?: (view: 'front' | 'back') => void
51
+ /** Called when a muscle group is tapped. */
52
+ onMusclePress?: (muscleGroup: MuscleGroup) => void
53
+ /** Currently highlighted muscle (from external selection). */
54
+ highlightedMuscle?: MuscleGroup | null
55
+ /** Show simplified (8 groups) or detailed (15 groups). */
56
+ mode?: 'simple' | 'detailed'
57
+ className?: string
58
+ }
59
+
60
+ interface LegendEntry {
61
+ key: string
62
+ name: string
63
+ status: VolumeStatus
64
+ intensity: number
65
+ sets: number
66
+ /** Detailed group reported to onMusclePress. */
67
+ muscle: MuscleGroup
68
+ }
69
+
70
+ /** Build the per-slug heatmap fill list, combining groups that share a slug. */
71
+ function buildSlugParts(data: BodyMapData[]): ExtendedBodyPart[] {
72
+ const bySlug = new Map<string, { status: VolumeStatus; intensity: number }>()
73
+ for (const d of data) {
74
+ for (const slug of MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []) {
75
+ const existing = bySlug.get(slug)
76
+ if (!existing || isMoreSevere(d.volumeStatus, existing.status)) {
77
+ bySlug.set(slug, { status: d.volumeStatus, intensity: d.intensity })
78
+ }
79
+ }
80
+ }
81
+ return Array.from(bySlug.entries()).map(([slug, v]) => ({
82
+ slug: slug as Slug,
83
+ color: getHeatmapColor(v.status, v.intensity),
84
+ }))
85
+ }
86
+
87
+ /** Collapse data into per-simple-group legend entries (summed sets). */
88
+ function buildSimpleLegend(data: BodyMapData[]): LegendEntry[] {
89
+ const bySimple = new Map<SimpleMuscleGroup, LegendEntry>()
90
+ for (const d of data) {
91
+ const simple = DETAILED_TO_SIMPLE[d.muscleGroup]
92
+ const existing = bySimple.get(simple)
93
+ if (!existing) {
94
+ bySimple.set(simple, {
95
+ key: simple,
96
+ name: SIMPLE_DISPLAY_NAMES[simple],
97
+ status: d.volumeStatus,
98
+ intensity: d.intensity,
99
+ sets: d.weeklySets,
100
+ muscle: d.muscleGroup,
101
+ })
102
+ continue
103
+ }
104
+ existing.sets += d.weeklySets
105
+ existing.intensity = Math.max(existing.intensity, d.intensity)
106
+ if (isMoreSevere(d.volumeStatus, existing.status)) {
107
+ existing.status = d.volumeStatus
108
+ existing.muscle = d.muscleGroup
109
+ }
110
+ }
111
+ return Array.from(bySimple.values())
112
+ }
113
+
114
+ /** One legend entry per data row, named by detailed group. */
115
+ function buildDetailedLegend(data: BodyMapData[]): LegendEntry[] {
116
+ return data.map((d) => ({
117
+ key: d.muscleGroup,
118
+ name: MUSCLE_DISPLAY_NAMES[d.muscleGroup],
119
+ status: d.volumeStatus,
120
+ intensity: d.intensity,
121
+ sets: d.weeklySets,
122
+ muscle: d.muscleGroup,
123
+ }))
124
+ }
125
+
126
+ /**
127
+ * Interactive SVG body map with tappable muscle groups and a volume heatmap.
128
+ * Renders the front or back view (toggle via onViewChange), colors each muscle
129
+ * by its weekly-volume status, and exposes an accessible muscle legend wired to
130
+ * onMusclePress. Phase 1 uses react-native-body-highlighter's combined slugs
131
+ * (deltoids as one path, lats/upper-back shared); swipe between views and the
132
+ * delt/lat SVG split are follow-ups.
133
+ *
134
+ * @example
135
+ * <BodyMap
136
+ * data={[{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 }]}
137
+ * view="front"
138
+ * onViewChange={setView}
139
+ * onMusclePress={setSelected}
140
+ * highlightedMuscle={selected}
141
+ * />
142
+ */
143
+ export function BodyMap({
144
+ data,
145
+ view,
146
+ onViewChange,
147
+ onMusclePress,
148
+ highlightedMuscle,
149
+ mode = 'detailed',
150
+ className,
151
+ ...props
152
+ }: BodyMapProps) {
153
+ const slugParts = useMemo(() => buildSlugParts(data), [data])
154
+ const legend = useMemo(
155
+ () => (mode === 'simple' ? buildSimpleLegend(data) : buildDetailedLegend(data)),
156
+ [data, mode]
157
+ )
158
+
159
+ const [highlight] = useState(() => new Animated.Value(highlightedMuscle ? 1 : 0))
160
+ useEffect(() => {
161
+ const animation = Animated.timing(highlight, {
162
+ toValue: highlightedMuscle ? 1 : 0,
163
+ duration: 250,
164
+ easing: Easing.out(Easing.ease),
165
+ useNativeDriver: false,
166
+ })
167
+ animation.start()
168
+ return () => animation.stop()
169
+ }, [highlightedMuscle, highlight])
170
+
171
+ const scale = highlight.interpolate({ inputRange: [0, 1], outputRange: [1, 1.02] })
172
+ const glowColor = highlightedMuscle
173
+ ? getHeatmapColor(
174
+ data.find((d) => d.muscleGroup === highlightedMuscle)?.volumeStatus,
175
+ data.find((d) => d.muscleGroup === highlightedMuscle)?.intensity ?? 0
176
+ )
177
+ : null
178
+
179
+ const handleBodyPress = (part: ExtendedBodyPart) => {
180
+ if (!onMusclePress || !part.slug) return
181
+ const fromData = data.find((d) =>
182
+ (MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []).includes(part.slug as string)
183
+ )
184
+ const muscle = fromData?.muscleGroup ?? SLUG_TO_PRIMARY_MUSCLE[part.slug]
185
+ if (muscle) onMusclePress(muscle)
186
+ }
187
+
188
+ return (
189
+ <View className={cn(className)} testID="body-map" {...props}>
190
+ <ViewToggle view={view} onViewChange={onViewChange} />
191
+
192
+ <Animated.View
193
+ style={[
194
+ {
195
+ width: 200 * BODY_SCALE,
196
+ alignSelf: 'center',
197
+ transform: [{ scale }],
198
+ },
199
+ glowColor ? { boxShadow: `0 0 14px 2px ${glowColor}` } : undefined,
200
+ ]}
201
+ // The SVG is decorative; the accessible muscle legend below is the
202
+ // screen-reader / keyboard interface.
203
+ aria-hidden
204
+ accessibilityElementsHidden
205
+ importantForAccessibility="no-hide-descendants"
206
+ testID="body-map-svg"
207
+ >
208
+ <Body
209
+ side={view}
210
+ data={slugParts}
211
+ scale={BODY_SCALE}
212
+ gender="male"
213
+ defaultFill={OUTLINE_FILL}
214
+ border={OUTLINE_BORDER}
215
+ onBodyPartPress={handleBodyPress}
216
+ />
217
+ </Animated.View>
218
+
219
+ {legend.length === 0 ? (
220
+ <Text
221
+ style={{
222
+ marginTop: 8,
223
+ fontSize: 12,
224
+ color: TEXT_SECONDARY,
225
+ textAlign: 'center',
226
+ fontFamily: 'Inter, sans-serif',
227
+ }}
228
+ testID="body-map-empty"
229
+ >
230
+ No training data
231
+ </Text>
232
+ ) : (
233
+ <View
234
+ className="flex-row flex-wrap"
235
+ style={{ marginTop: 10, gap: 6, justifyContent: 'center' }}
236
+ testID="body-map-legend"
237
+ >
238
+ {legend.map((entry) => (
239
+ <MuscleButton
240
+ key={entry.key}
241
+ entry={entry}
242
+ highlighted={highlightedMuscle === entry.muscle}
243
+ onMusclePress={onMusclePress}
244
+ />
245
+ ))}
246
+ </View>
247
+ )}
248
+ </View>
249
+ )
250
+ }
251
+
252
+ interface ViewToggleProps {
253
+ view: 'front' | 'back'
254
+ onViewChange?: (view: 'front' | 'back') => void
255
+ }
256
+
257
+ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
258
+ return (
259
+ <View
260
+ className="flex-row self-center"
261
+ style={{ gap: 4, marginBottom: 8 }}
262
+ testID="body-map-view-toggle"
263
+ >
264
+ {(['front', 'back'] as const).map((side) => {
265
+ const active = view === side
266
+ return (
267
+ <Pressable
268
+ key={side}
269
+ onPress={() => onViewChange?.(side)}
270
+ accessibilityRole="button"
271
+ accessibilityLabel={`Show ${side} view`}
272
+ aria-pressed={active}
273
+ disabled={!onViewChange}
274
+ style={{
275
+ paddingHorizontal: 12,
276
+ paddingVertical: 4,
277
+ borderRadius: 9999,
278
+ backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
279
+ borderWidth: 1,
280
+ borderColor: active ? '#FF7900' : WORKOUT_TOKENS.border.strong,
281
+ }}
282
+ testID={`body-map-toggle-${side}`}
283
+ >
284
+ <Text
285
+ style={{
286
+ fontSize: 12,
287
+ fontFamily: 'Inter, sans-serif',
288
+ fontWeight: active ? '700' : '500',
289
+ color: active ? '#FF7900' : TEXT_SECONDARY,
290
+ textTransform: 'capitalize',
291
+ }}
292
+ >
293
+ {side}
294
+ </Text>
295
+ </Pressable>
296
+ )
297
+ })}
298
+ </View>
299
+ )
300
+ }
301
+
302
+ interface MuscleButtonProps {
303
+ entry: LegendEntry
304
+ highlighted: boolean
305
+ onMusclePress?: (muscleGroup: MuscleGroup) => void
306
+ }
307
+
308
+ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps) {
309
+ const dotColor = getHeatmapColor(entry.status, entry.intensity)
310
+ const label = `${entry.name}, ${VOLUME_STATUS_LABELS[entry.status]}, ${entry.sets} sets this week`
311
+ return (
312
+ <Pressable
313
+ onPress={() => onMusclePress?.(entry.muscle)}
314
+ accessibilityRole="button"
315
+ accessibilityLabel={label}
316
+ aria-pressed={highlighted}
317
+ style={{
318
+ flexDirection: 'row',
319
+ alignItems: 'center',
320
+ gap: 5,
321
+ paddingHorizontal: 8,
322
+ paddingVertical: 3,
323
+ borderRadius: 9999,
324
+ borderWidth: 1,
325
+ borderColor: highlighted ? '#FF7900' : WORKOUT_TOKENS.border.default,
326
+ backgroundColor: WORKOUT_TOKENS.surface.raised,
327
+ }}
328
+ testID={`body-map-muscle-${entry.key}`}
329
+ >
330
+ <View
331
+ style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: dotColor }}
332
+ accessibilityElementsHidden
333
+ testID={`body-map-muscle-dot-${entry.key}`}
334
+ />
335
+ <Text
336
+ style={{
337
+ fontSize: 11,
338
+ fontFamily: 'Inter, sans-serif',
339
+ fontWeight: '500',
340
+ color: TEXT_PRIMARY,
341
+ }}
342
+ accessibilityElementsHidden
343
+ >
344
+ {entry.name}
345
+ </Text>
346
+ </Pressable>
347
+ )
348
+ }
@@ -0,0 +1,171 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View, Text, Pressable } from 'react-native'
4
+ import {
5
+ BodyMapDetailPanel,
6
+ type ContributingExercise,
7
+ type UpcomingExercise,
8
+ } from './BodyMapDetailPanel'
9
+ import { MuscleGroup } from './muscleTaxonomy'
10
+
11
+ const contributing: ContributingExercise[] = [
12
+ { name: 'Barbell Bench Press', sets: 4, contributionWeight: 1 },
13
+ { name: 'Incline Dumbbell Press', sets: 3, contributionWeight: 1 },
14
+ { name: 'Cable Fly', sets: 3, contributionWeight: 0.75 },
15
+ { name: 'Overhead Press', sets: 2, contributionWeight: 0.33 },
16
+ ]
17
+
18
+ const upcoming: UpcomingExercise[] = [
19
+ { name: 'Dips', workoutName: 'Push B', sets: 3 },
20
+ { name: 'Machine Chest Press', workoutName: 'Push B', sets: 3 },
21
+ ]
22
+
23
+ const meta: Meta<typeof BodyMapDetailPanel> = {
24
+ title: 'Custom/Workout/BodyMapDetailPanel',
25
+ component: BodyMapDetailPanel,
26
+ tags: ['autodocs'],
27
+ decorators: [
28
+ (Story) => (
29
+ <View style={{ position: 'relative', height: 640, width: 380, backgroundColor: '#0E0E0E' }}>
30
+ <Story />
31
+ </View>
32
+ ),
33
+ ],
34
+ argTypes: {
35
+ displayName: { control: 'text', description: 'Human-readable muscle name' },
36
+ weeklySets: { control: 'number', description: 'Weekly effective sets logged' },
37
+ volumeStatus: {
38
+ control: 'select',
39
+ options: ['under', 'maintenance', 'productive', 'over'],
40
+ description: 'Volume status relative to landmarks',
41
+ },
42
+ isOpen: { control: 'boolean', description: 'Whether the bottom sheet is visible' },
43
+ lastTrained: { control: 'text', description: 'Last trained label' },
44
+ },
45
+ }
46
+
47
+ export default meta
48
+ type Story = StoryObj<typeof BodyMapDetailPanel>
49
+
50
+ export const Default: Story = {
51
+ args: {
52
+ muscleGroup: MuscleGroup.CHEST,
53
+ displayName: 'Chest',
54
+ weeklySets: 14,
55
+ landmarks: { mev: 8, mav: 14, mrv: 20 },
56
+ volumeStatus: 'productive',
57
+ lastTrained: '2 days ago',
58
+ weeklyHistory: [8, 10, 12, 11, 13, 14],
59
+ contributingExercises: contributing,
60
+ upcomingExercises: upcoming,
61
+ isOpen: true,
62
+ onClose: () => {},
63
+ onViewExercises: () => {},
64
+ },
65
+ }
66
+
67
+ export const UnderTrained: Story = {
68
+ args: {
69
+ ...Default.args,
70
+ displayName: 'Rear Delts',
71
+ muscleGroup: MuscleGroup.REAR_DELTS,
72
+ weeklySets: 4,
73
+ landmarks: { mev: 6, mav: 12, mrv: 18 },
74
+ volumeStatus: 'under',
75
+ weeklyHistory: [2, 3, 3, 4],
76
+ },
77
+ }
78
+
79
+ export const Maintenance: Story = {
80
+ args: {
81
+ ...Default.args,
82
+ displayName: 'Biceps',
83
+ muscleGroup: MuscleGroup.BICEPS,
84
+ weeklySets: 8,
85
+ landmarks: { mev: 4, mav: 10, mrv: 18 },
86
+ volumeStatus: 'maintenance',
87
+ },
88
+ }
89
+
90
+ export const Productive: Story = {
91
+ args: { ...Default.args },
92
+ }
93
+
94
+ export const OverReaching: Story = {
95
+ args: {
96
+ ...Default.args,
97
+ displayName: 'Quads',
98
+ muscleGroup: MuscleGroup.QUADS,
99
+ weeklySets: 20,
100
+ landmarks: { mev: 6, mav: 12, mrv: 18 },
101
+ volumeStatus: 'over',
102
+ weeklyHistory: [12, 14, 16, 18, 20],
103
+ },
104
+ }
105
+
106
+ export const Minimal: Story = {
107
+ args: {
108
+ muscleGroup: MuscleGroup.CALVES,
109
+ displayName: 'Calves',
110
+ weeklySets: 10,
111
+ landmarks: { mev: 6, mav: 10, mrv: 16 },
112
+ volumeStatus: 'maintenance',
113
+ isOpen: true,
114
+ onClose: () => {},
115
+ },
116
+ }
117
+
118
+ export const WithoutHistory: Story = {
119
+ args: {
120
+ ...Default.args,
121
+ weeklyHistory: undefined,
122
+ },
123
+ }
124
+
125
+ export const WithoutUpcoming: Story = {
126
+ args: {
127
+ ...Default.args,
128
+ upcomingExercises: [],
129
+ },
130
+ }
131
+
132
+ function InteractiveBodyMapDetailPanel() {
133
+ const [open, setOpen] = useState(false)
134
+ return (
135
+ <View style={{ position: 'relative', height: 640, width: 380, backgroundColor: '#0E0E0E', padding: 16 }}>
136
+ <Pressable
137
+ onPress={() => setOpen(true)}
138
+ accessibilityRole="button"
139
+ style={{
140
+ alignSelf: 'flex-start',
141
+ backgroundColor: '#FF7900',
142
+ paddingVertical: 10,
143
+ paddingHorizontal: 20,
144
+ borderRadius: 8,
145
+ }}
146
+ >
147
+ <Text style={{ color: '#FFFFFF', fontWeight: '700', fontFamily: 'Inter, sans-serif' }}>
148
+ Tap Chest
149
+ </Text>
150
+ </Pressable>
151
+ <BodyMapDetailPanel
152
+ muscleGroup={MuscleGroup.CHEST}
153
+ displayName="Chest"
154
+ weeklySets={14}
155
+ landmarks={{ mev: 8, mav: 14, mrv: 20 }}
156
+ volumeStatus="productive"
157
+ lastTrained="2 days ago"
158
+ weeklyHistory={[8, 10, 12, 11, 13, 14]}
159
+ contributingExercises={contributing}
160
+ upcomingExercises={upcoming}
161
+ isOpen={open}
162
+ onClose={() => setOpen(false)}
163
+ onViewExercises={() => {}}
164
+ />
165
+ </View>
166
+ )
167
+ }
168
+
169
+ export const Interactive: Story = {
170
+ render: () => <InteractiveBodyMapDetailPanel />,
171
+ }