@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,113 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState, type ReactNode } from 'react'
3
+ import { View, Pressable, Animated, type ViewProps } from 'react-native'
4
+
5
+ export type BaseBadgeVariant = 'plain' | 'pr'
6
+ export type BaseBadgeSize = 'sm' | 'md' | 'lg'
7
+
8
+ export interface BaseBadgeProps extends ViewProps {
9
+ /** Visual variant — plain (neutral) or pr (brand-primary-subtle) */
10
+ variant?: BaseBadgeVariant
11
+ /** Size variant */
12
+ size?: BaseBadgeSize
13
+ /** Optional icon rendered left of children; hidden from a11y tree */
14
+ icon?: ReactNode
15
+ onPress?: () => void
16
+ className?: string
17
+ children?: ReactNode
18
+ }
19
+
20
+ export const baseBadgeSizeConfig: Record<
21
+ BaseBadgeSize,
22
+ { fontSize: number; paddingH: number; paddingV: number; iconSize: number }
23
+ > = {
24
+ sm: { fontSize: 9, paddingH: 6, paddingV: 2, iconSize: 10 },
25
+ md: { fontSize: 10, paddingH: 8, paddingV: 2, iconSize: 12 },
26
+ lg: { fontSize: 12, paddingH: 10, paddingV: 4, iconSize: 14 },
27
+ }
28
+
29
+ const variantColors: Record<
30
+ BaseBadgeVariant,
31
+ { backgroundColor: string; borderColor: string }
32
+ > = {
33
+ plain: { backgroundColor: '#1C1C1C', borderColor: '#1F1F1F' },
34
+ pr: {
35
+ backgroundColor: 'rgba(255, 121, 0, 0.12)',
36
+ borderColor: 'rgba(255, 121, 0, 0.12)',
37
+ },
38
+ }
39
+
40
+ export function BaseBadge({
41
+ variant = 'plain',
42
+ size = 'md',
43
+ icon,
44
+ onPress,
45
+ className,
46
+ children,
47
+ ...props
48
+ }: BaseBadgeProps) {
49
+ const [scaleAnim] = useState(() => new Animated.Value(1))
50
+ const config = baseBadgeSizeConfig[size]
51
+ const colors = variantColors[variant]
52
+
53
+ const handlePressIn = () => {
54
+ Animated.timing(scaleAnim, {
55
+ toValue: 0.97,
56
+ duration: 150,
57
+ useNativeDriver: true,
58
+ }).start()
59
+ }
60
+
61
+ const handlePressOut = () => {
62
+ Animated.timing(scaleAnim, {
63
+ toValue: 1,
64
+ duration: 150,
65
+ useNativeDriver: true,
66
+ }).start()
67
+ }
68
+
69
+ const badge = (
70
+ <View
71
+ className={className}
72
+ style={{
73
+ flexDirection: 'row',
74
+ alignItems: 'center',
75
+ gap: 3,
76
+ // borderRadius 2 is intentional per workout-tokens.ts (squared-off pill)
77
+ borderRadius: 2,
78
+ borderWidth: 1,
79
+ borderColor: colors.borderColor,
80
+ backgroundColor: colors.backgroundColor,
81
+ paddingHorizontal: config.paddingH,
82
+ paddingVertical: config.paddingV,
83
+ }}
84
+ {...props}
85
+ >
86
+ {icon != null && (
87
+ <View accessibilityElementsHidden testID="base-badge-icon">
88
+ {icon}
89
+ </View>
90
+ )}
91
+ {children}
92
+ </View>
93
+ )
94
+
95
+ if (onPress) {
96
+ return (
97
+ <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
98
+ <Pressable
99
+ onPress={onPress}
100
+ onPressIn={handlePressIn}
101
+ onPressOut={handlePressOut}
102
+ accessibilityRole="button"
103
+ accessibilityLabel={props.accessibilityLabel}
104
+ testID="base-badge-pressable"
105
+ >
106
+ {badge}
107
+ </Pressable>
108
+ </Animated.View>
109
+ )
110
+ }
111
+
112
+ return badge
113
+ }
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View } from 'react-native'
4
+ import { BodyMap, type BodyMapData } from './BodyMap'
5
+ import { MuscleGroup } from './muscleTaxonomy'
6
+
7
+ const sampleData: BodyMapData[] = [
8
+ { muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 },
9
+ {
10
+ muscleGroup: MuscleGroup.FRONT_DELTS,
11
+ intensity: 0.5,
12
+ volumeStatus: 'maintenance',
13
+ weeklySets: 5,
14
+ },
15
+ {
16
+ muscleGroup: MuscleGroup.SIDE_DELTS,
17
+ intensity: 0.6,
18
+ volumeStatus: 'productive',
19
+ weeklySets: 10,
20
+ },
21
+ { muscleGroup: MuscleGroup.BICEPS, intensity: 0.3, volumeStatus: 'under', weeklySets: 3 },
22
+ { muscleGroup: MuscleGroup.TRICEPS, intensity: 0.65, volumeStatus: 'productive', weeklySets: 8 },
23
+ { muscleGroup: MuscleGroup.ABS, intensity: 0.4, volumeStatus: 'maintenance', weeklySets: 6 },
24
+ {
25
+ muscleGroup: MuscleGroup.OBLIQUES,
26
+ intensity: 0.35,
27
+ volumeStatus: 'maintenance',
28
+ weeklySets: 4,
29
+ },
30
+ { muscleGroup: MuscleGroup.QUADS, intensity: 0.95, volumeStatus: 'over', weeklySets: 20 },
31
+ ]
32
+
33
+ const backData: BodyMapData[] = [
34
+ { muscleGroup: MuscleGroup.LATS, intensity: 0.6, volumeStatus: 'productive', weeklySets: 11 },
35
+ {
36
+ muscleGroup: MuscleGroup.UPPER_BACK,
37
+ intensity: 0.5,
38
+ volumeStatus: 'maintenance',
39
+ weeklySets: 8,
40
+ },
41
+ { muscleGroup: MuscleGroup.REAR_DELTS, intensity: 0.3, volumeStatus: 'under', weeklySets: 4 },
42
+ { muscleGroup: MuscleGroup.TRICEPS, intensity: 0.65, volumeStatus: 'productive', weeklySets: 8 },
43
+ { muscleGroup: MuscleGroup.GLUTES, intensity: 0.7, volumeStatus: 'productive', weeklySets: 10 },
44
+ {
45
+ muscleGroup: MuscleGroup.HAMSTRINGS,
46
+ intensity: 0.55,
47
+ volumeStatus: 'productive',
48
+ weeklySets: 9,
49
+ },
50
+ { muscleGroup: MuscleGroup.CALVES, intensity: 0.2, volumeStatus: 'under', weeklySets: 3 },
51
+ ]
52
+
53
+ const meta: Meta<typeof BodyMap> = {
54
+ title: 'Custom/Workout/BodyMap',
55
+ component: BodyMap,
56
+ tags: ['autodocs'],
57
+ argTypes: {
58
+ view: { control: 'inline-radio', options: ['front', 'back'] },
59
+ mode: { control: 'inline-radio', options: ['simple', 'detailed'] },
60
+ },
61
+ decorators: [
62
+ (Story) => (
63
+ <View style={{ padding: 16, maxWidth: 320 }}>
64
+ <Story />
65
+ </View>
66
+ ),
67
+ ],
68
+ }
69
+
70
+ export default meta
71
+ type Story = StoryObj<typeof BodyMap>
72
+
73
+ export const Front: Story = {
74
+ args: { data: sampleData, view: 'front', mode: 'detailed' },
75
+ }
76
+
77
+ export const Back: Story = {
78
+ args: { data: backData, view: 'back', mode: 'detailed' },
79
+ }
80
+
81
+ export const Simple: Story = {
82
+ args: { data: sampleData, view: 'front', mode: 'simple' },
83
+ }
84
+
85
+ export const Detailed: Story = {
86
+ args: { data: sampleData, view: 'front', mode: 'detailed' },
87
+ }
88
+
89
+ export const Highlighted: Story = {
90
+ args: {
91
+ data: sampleData,
92
+ view: 'front',
93
+ mode: 'detailed',
94
+ highlightedMuscle: MuscleGroup.QUADS,
95
+ },
96
+ }
97
+
98
+ export const Empty: Story = {
99
+ args: { data: [], view: 'front' },
100
+ }
101
+
102
+ function InteractiveBodyMap() {
103
+ const [view, setView] = useState<'front' | 'back'>('front')
104
+ const [selected, setSelected] = useState<MuscleGroup | null>(null)
105
+ return (
106
+ <View style={{ maxWidth: 320 }}>
107
+ <BodyMap
108
+ data={view === 'front' ? sampleData : backData}
109
+ view={view}
110
+ onViewChange={setView}
111
+ onMusclePress={(m) => setSelected((cur) => (cur === m ? null : m))}
112
+ highlightedMuscle={selected}
113
+ mode="detailed"
114
+ />
115
+ </View>
116
+ )
117
+ }
118
+
119
+ export const Interactive: Story = {
120
+ render: () => <InteractiveBodyMap />,
121
+ }
@@ -0,0 +1,124 @@
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 { BodyMap, type BodyMapData } from './BodyMap'
5
+ import { MuscleGroup } from './muscleTaxonomy'
6
+
7
+ const data: BodyMapData[] = [
8
+ { muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 },
9
+ { muscleGroup: MuscleGroup.LATS, intensity: 0.4, volumeStatus: 'under', weeklySets: 6 },
10
+ { muscleGroup: MuscleGroup.QUADS, intensity: 0.9, volumeStatus: 'over', weeklySets: 20 },
11
+ ]
12
+
13
+ describe('BodyMap', () => {
14
+ describe('rendering', () => {
15
+ it('renders the container, SVG, and view toggle', () => {
16
+ render(<BodyMap data={data} view="front" />)
17
+ expect(screen.getByTestId('body-map')).toBeInTheDocument()
18
+ expect(screen.getByTestId('body-map-svg')).toBeInTheDocument()
19
+ expect(screen.getByTestId('body-map-view-toggle')).toBeInTheDocument()
20
+ })
21
+
22
+ it('renders the SVG body for both front and back views', () => {
23
+ const { rerender, container } = render(<BodyMap data={data} view="front" />)
24
+ expect(container.querySelector('svg')).toBeInTheDocument()
25
+ rerender(<BodyMap data={data} view="back" />)
26
+ expect(container.querySelector('svg')).toBeInTheDocument()
27
+ })
28
+
29
+ it('renders one legend button per muscle in detailed mode', () => {
30
+ render(<BodyMap data={data} view="front" mode="detailed" />)
31
+ expect(screen.getByTestId('body-map-muscle-chest')).toBeInTheDocument()
32
+ expect(screen.getByTestId('body-map-muscle-lats')).toBeInTheDocument()
33
+ expect(screen.getByTestId('body-map-muscle-quads')).toBeInTheDocument()
34
+ })
35
+
36
+ it('aggregates muscles into simple groups in simple mode', () => {
37
+ render(<BodyMap data={data} view="front" mode="simple" />)
38
+ // chest -> chest, lats -> back, quads -> legs
39
+ expect(screen.getByTestId('body-map-muscle-chest')).toBeInTheDocument()
40
+ expect(screen.getByTestId('body-map-muscle-back')).toBeInTheDocument()
41
+ expect(screen.getByTestId('body-map-muscle-legs')).toBeInTheDocument()
42
+ expect(screen.queryByTestId('body-map-muscle-lats')).not.toBeInTheDocument()
43
+ })
44
+
45
+ it('renders an empty hint when there is no data', () => {
46
+ render(<BodyMap data={[]} view="front" />)
47
+ expect(screen.getByTestId('body-map-empty')).toBeInTheDocument()
48
+ expect(screen.queryByTestId('body-map-legend')).not.toBeInTheDocument()
49
+ })
50
+ })
51
+
52
+ describe('interaction', () => {
53
+ it('calls onMusclePress with the tapped muscle group', () => {
54
+ const onMusclePress = vi.fn()
55
+ render(<BodyMap data={data} view="front" onMusclePress={onMusclePress} />)
56
+ fireEvent.click(screen.getByTestId('body-map-muscle-chest'))
57
+ expect(onMusclePress).toHaveBeenCalledWith(MuscleGroup.CHEST)
58
+ })
59
+
60
+ it('reports a representative detailed muscle from a simple group tap', () => {
61
+ const onMusclePress = vi.fn()
62
+ render(<BodyMap data={data} view="front" mode="simple" onMusclePress={onMusclePress} />)
63
+ fireEvent.click(screen.getByTestId('body-map-muscle-back'))
64
+ expect(onMusclePress).toHaveBeenCalledWith(MuscleGroup.LATS)
65
+ })
66
+
67
+ it('calls onViewChange from the front/back toggle', () => {
68
+ const onViewChange = vi.fn()
69
+ render(<BodyMap data={data} view="front" onViewChange={onViewChange} />)
70
+ fireEvent.click(screen.getByTestId('body-map-toggle-back'))
71
+ expect(onViewChange).toHaveBeenCalledWith('back')
72
+ fireEvent.click(screen.getByTestId('body-map-toggle-front'))
73
+ expect(onViewChange).toHaveBeenCalledWith('front')
74
+ })
75
+ })
76
+
77
+ describe('highlighted muscle', () => {
78
+ it('marks the highlighted muscle button as pressed', () => {
79
+ render(<BodyMap data={data} view="front" highlightedMuscle={MuscleGroup.CHEST} />)
80
+ expect(screen.getByTestId('body-map-muscle-chest')).toHaveAttribute('aria-pressed', 'true')
81
+ expect(screen.getByTestId('body-map-muscle-lats')).toHaveAttribute('aria-pressed', 'false')
82
+ })
83
+
84
+ it('applies an edge glow when a muscle is highlighted', () => {
85
+ render(<BodyMap data={data} view="front" highlightedMuscle={MuscleGroup.QUADS} />)
86
+ const svg = screen.getByTestId('body-map-svg')
87
+ expect(svg.getAttribute('style') ?? '').toContain('box-shadow')
88
+ })
89
+ })
90
+
91
+ describe('accessibility', () => {
92
+ it('labels each muscle button with name, status, and weekly sets', () => {
93
+ render(<BodyMap data={data} view="front" />)
94
+ expect(screen.getByLabelText('Chest, productive, 12 sets this week')).toBeInTheDocument()
95
+ expect(screen.getByLabelText('Lats, under-trained, 6 sets this week')).toBeInTheDocument()
96
+ })
97
+
98
+ it('labels the view toggle buttons', () => {
99
+ render(<BodyMap data={data} view="front" onViewChange={vi.fn()} />)
100
+ expect(screen.getByLabelText('Show front view')).toBeInTheDocument()
101
+ expect(screen.getByLabelText('Show back view')).toBeInTheDocument()
102
+ })
103
+
104
+ it('has no accessibility violations with data', async () => {
105
+ const { container } = render(
106
+ <BodyMap
107
+ data={data}
108
+ view="front"
109
+ onViewChange={vi.fn()}
110
+ onMusclePress={vi.fn()}
111
+ highlightedMuscle={MuscleGroup.CHEST}
112
+ />
113
+ )
114
+ const results = await axe(container)
115
+ expect(results).toHaveNoViolations()
116
+ })
117
+
118
+ it('has no accessibility violations when empty', async () => {
119
+ const { container } = render(<BodyMap data={[]} view="back" />)
120
+ const results = await axe(container)
121
+ expect(results).toHaveNoViolations()
122
+ })
123
+ })
124
+ })
@@ -0,0 +1,347 @@
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 {
7
+ MuscleGroup,
8
+ SimpleMuscleGroup,
9
+ MUSCLE_TO_SVG_SLUGS,
10
+ MUSCLE_DISPLAY_NAMES,
11
+ SIMPLE_DISPLAY_NAMES,
12
+ DETAILED_TO_SIMPLE,
13
+ SLUG_TO_PRIMARY_MUSCLE,
14
+ VOLUME_STATUS_LABELS,
15
+ getHeatmapColor,
16
+ isMoreSevere,
17
+ type VolumeStatus,
18
+ } from './muscleTaxonomy'
19
+
20
+ /**
21
+ * react-native-body-highlighter is published as a CommonJS default export; the
22
+ * interop keeps it working whether the bundler unwraps the default or not.
23
+ */
24
+ const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter }).default ??
25
+ BodyHighlighter) as typeof BodyHighlighter
26
+
27
+ const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
28
+ const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
29
+ const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
30
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
31
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
32
+
33
+ export interface BodyMapData {
34
+ muscleGroup: MuscleGroup
35
+ /** Intensity 0-1 for heatmap color. */
36
+ intensity: number
37
+ /** Volume status for tooltip / heatmap token. */
38
+ volumeStatus: VolumeStatus
39
+ /** Weekly effective sets. */
40
+ weeklySets: number
41
+ }
42
+
43
+ export interface BodyMapProps extends ViewProps {
44
+ /** Muscle group intensity data. */
45
+ data: BodyMapData[]
46
+ /** Which view to show. */
47
+ view: 'front' | 'back'
48
+ /** Toggle between front/back. */
49
+ onViewChange?: (view: 'front' | 'back') => void
50
+ /** Called when a muscle group is tapped. */
51
+ onMusclePress?: (muscleGroup: MuscleGroup) => void
52
+ /** Currently highlighted muscle (from external selection). */
53
+ highlightedMuscle?: MuscleGroup | null
54
+ /** Show simplified (8 groups) or detailed (15 groups). */
55
+ mode?: 'simple' | 'detailed'
56
+ className?: string
57
+ }
58
+
59
+ interface LegendEntry {
60
+ key: string
61
+ name: string
62
+ status: VolumeStatus
63
+ intensity: number
64
+ sets: number
65
+ /** Detailed group reported to onMusclePress. */
66
+ muscle: MuscleGroup
67
+ }
68
+
69
+ /** Build the per-slug heatmap fill list, combining groups that share a slug. */
70
+ function buildSlugParts(data: BodyMapData[]): ExtendedBodyPart[] {
71
+ const bySlug = new Map<string, { status: VolumeStatus; intensity: number }>()
72
+ for (const d of data) {
73
+ for (const slug of MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []) {
74
+ const existing = bySlug.get(slug)
75
+ if (!existing || isMoreSevere(d.volumeStatus, existing.status)) {
76
+ bySlug.set(slug, { status: d.volumeStatus, intensity: d.intensity })
77
+ }
78
+ }
79
+ }
80
+ return Array.from(bySlug.entries()).map(([slug, v]) => ({
81
+ slug: slug as Slug,
82
+ color: getHeatmapColor(v.status, v.intensity),
83
+ }))
84
+ }
85
+
86
+ /** Collapse data into per-simple-group legend entries (summed sets). */
87
+ function buildSimpleLegend(data: BodyMapData[]): LegendEntry[] {
88
+ const bySimple = new Map<SimpleMuscleGroup, LegendEntry>()
89
+ for (const d of data) {
90
+ const simple = DETAILED_TO_SIMPLE[d.muscleGroup]
91
+ const existing = bySimple.get(simple)
92
+ if (!existing) {
93
+ bySimple.set(simple, {
94
+ key: simple,
95
+ name: SIMPLE_DISPLAY_NAMES[simple],
96
+ status: d.volumeStatus,
97
+ intensity: d.intensity,
98
+ sets: d.weeklySets,
99
+ muscle: d.muscleGroup,
100
+ })
101
+ continue
102
+ }
103
+ existing.sets += d.weeklySets
104
+ existing.intensity = Math.max(existing.intensity, d.intensity)
105
+ if (isMoreSevere(d.volumeStatus, existing.status)) {
106
+ existing.status = d.volumeStatus
107
+ existing.muscle = d.muscleGroup
108
+ }
109
+ }
110
+ return Array.from(bySimple.values())
111
+ }
112
+
113
+ /** One legend entry per data row, named by detailed group. */
114
+ function buildDetailedLegend(data: BodyMapData[]): LegendEntry[] {
115
+ return data.map((d) => ({
116
+ key: d.muscleGroup,
117
+ name: MUSCLE_DISPLAY_NAMES[d.muscleGroup],
118
+ status: d.volumeStatus,
119
+ intensity: d.intensity,
120
+ sets: d.weeklySets,
121
+ muscle: d.muscleGroup,
122
+ }))
123
+ }
124
+
125
+ /**
126
+ * Interactive SVG body map with tappable muscle groups and a volume heatmap.
127
+ * Renders the front or back view (toggle via onViewChange), colors each muscle
128
+ * by its weekly-volume status, and exposes an accessible muscle legend wired to
129
+ * onMusclePress. Phase 1 uses react-native-body-highlighter's combined slugs
130
+ * (deltoids as one path, lats/upper-back shared); swipe between views and the
131
+ * delt/lat SVG split are follow-ups.
132
+ *
133
+ * @example
134
+ * <BodyMap
135
+ * data={[{ muscleGroup: MuscleGroup.CHEST, intensity: 0.7, volumeStatus: 'productive', weeklySets: 12 }]}
136
+ * view="front"
137
+ * onViewChange={setView}
138
+ * onMusclePress={setSelected}
139
+ * highlightedMuscle={selected}
140
+ * />
141
+ */
142
+ export function BodyMap({
143
+ data,
144
+ view,
145
+ onViewChange,
146
+ onMusclePress,
147
+ highlightedMuscle,
148
+ mode = 'detailed',
149
+ className,
150
+ ...props
151
+ }: BodyMapProps) {
152
+ const slugParts = useMemo(() => buildSlugParts(data), [data])
153
+ const legend = useMemo(
154
+ () => (mode === 'simple' ? buildSimpleLegend(data) : buildDetailedLegend(data)),
155
+ [data, mode]
156
+ )
157
+
158
+ const [highlight] = useState(() => new Animated.Value(highlightedMuscle ? 1 : 0))
159
+ useEffect(() => {
160
+ const animation = Animated.timing(highlight, {
161
+ toValue: highlightedMuscle ? 1 : 0,
162
+ duration: 250,
163
+ easing: Easing.out(Easing.ease),
164
+ useNativeDriver: false,
165
+ })
166
+ animation.start()
167
+ return () => animation.stop()
168
+ }, [highlightedMuscle, highlight])
169
+
170
+ const scale = highlight.interpolate({ inputRange: [0, 1], outputRange: [1, 1.02] })
171
+ const glowColor = highlightedMuscle
172
+ ? getHeatmapColor(
173
+ data.find((d) => d.muscleGroup === highlightedMuscle)?.volumeStatus,
174
+ data.find((d) => d.muscleGroup === highlightedMuscle)?.intensity ?? 0
175
+ )
176
+ : null
177
+
178
+ const handleBodyPress = (part: ExtendedBodyPart) => {
179
+ if (!onMusclePress || !part.slug) return
180
+ const fromData = data.find((d) =>
181
+ (MUSCLE_TO_SVG_SLUGS[d.muscleGroup] ?? []).includes(part.slug as string)
182
+ )
183
+ const muscle = fromData?.muscleGroup ?? SLUG_TO_PRIMARY_MUSCLE[part.slug]
184
+ if (muscle) onMusclePress(muscle)
185
+ }
186
+
187
+ return (
188
+ <View className={cn(className)} testID="body-map" {...props}>
189
+ <ViewToggle view={view} onViewChange={onViewChange} />
190
+
191
+ <Animated.View
192
+ style={[
193
+ {
194
+ width: 200 * BODY_SCALE,
195
+ alignSelf: 'center',
196
+ transform: [{ scale }],
197
+ },
198
+ glowColor ? { boxShadow: `0 0 14px 2px ${glowColor}` } : undefined,
199
+ ]}
200
+ // The SVG is decorative; the accessible muscle legend below is the
201
+ // screen-reader / keyboard interface.
202
+ aria-hidden
203
+ accessibilityElementsHidden
204
+ importantForAccessibility="no-hide-descendants"
205
+ testID="body-map-svg"
206
+ >
207
+ <Body
208
+ side={view}
209
+ data={slugParts}
210
+ scale={BODY_SCALE}
211
+ gender="male"
212
+ defaultFill={OUTLINE_FILL}
213
+ border={OUTLINE_BORDER}
214
+ onBodyPartPress={handleBodyPress}
215
+ />
216
+ </Animated.View>
217
+
218
+ {legend.length === 0 ? (
219
+ <Text
220
+ style={{
221
+ marginTop: 8,
222
+ fontSize: 12,
223
+ color: TEXT_SECONDARY,
224
+ textAlign: 'center',
225
+ fontFamily: 'Inter, sans-serif',
226
+ }}
227
+ testID="body-map-empty"
228
+ >
229
+ No training data
230
+ </Text>
231
+ ) : (
232
+ <View
233
+ className="flex-row flex-wrap"
234
+ style={{ marginTop: 10, gap: 6, justifyContent: 'center' }}
235
+ testID="body-map-legend"
236
+ >
237
+ {legend.map((entry) => (
238
+ <MuscleButton
239
+ key={entry.key}
240
+ entry={entry}
241
+ highlighted={highlightedMuscle === entry.muscle}
242
+ onMusclePress={onMusclePress}
243
+ />
244
+ ))}
245
+ </View>
246
+ )}
247
+ </View>
248
+ )
249
+ }
250
+
251
+ interface ViewToggleProps {
252
+ view: 'front' | 'back'
253
+ onViewChange?: (view: 'front' | 'back') => void
254
+ }
255
+
256
+ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
257
+ return (
258
+ <View
259
+ className="flex-row self-center"
260
+ style={{ gap: 4, marginBottom: 8 }}
261
+ testID="body-map-view-toggle"
262
+ >
263
+ {(['front', 'back'] as const).map((side) => {
264
+ const active = view === side
265
+ return (
266
+ <Pressable
267
+ key={side}
268
+ onPress={() => onViewChange?.(side)}
269
+ accessibilityRole="button"
270
+ accessibilityLabel={`Show ${side} view`}
271
+ aria-pressed={active}
272
+ disabled={!onViewChange}
273
+ style={{
274
+ paddingHorizontal: 12,
275
+ paddingVertical: 4,
276
+ borderRadius: 9999,
277
+ backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
278
+ borderWidth: 1,
279
+ borderColor: active ? '#FF7900' : 'var(--color-border-strong)',
280
+ }}
281
+ testID={`body-map-toggle-${side}`}
282
+ >
283
+ <Text
284
+ style={{
285
+ fontSize: 12,
286
+ fontFamily: 'Inter, sans-serif',
287
+ fontWeight: active ? '700' : '500',
288
+ color: active ? '#FF7900' : TEXT_SECONDARY,
289
+ textTransform: 'capitalize',
290
+ }}
291
+ >
292
+ {side}
293
+ </Text>
294
+ </Pressable>
295
+ )
296
+ })}
297
+ </View>
298
+ )
299
+ }
300
+
301
+ interface MuscleButtonProps {
302
+ entry: LegendEntry
303
+ highlighted: boolean
304
+ onMusclePress?: (muscleGroup: MuscleGroup) => void
305
+ }
306
+
307
+ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps) {
308
+ const dotColor = getHeatmapColor(entry.status, entry.intensity)
309
+ const label = `${entry.name}, ${VOLUME_STATUS_LABELS[entry.status]}, ${entry.sets} sets this week`
310
+ return (
311
+ <Pressable
312
+ onPress={() => onMusclePress?.(entry.muscle)}
313
+ accessibilityRole="button"
314
+ accessibilityLabel={label}
315
+ aria-pressed={highlighted}
316
+ style={{
317
+ flexDirection: 'row',
318
+ alignItems: 'center',
319
+ gap: 5,
320
+ paddingHorizontal: 8,
321
+ paddingVertical: 3,
322
+ borderRadius: 9999,
323
+ borderWidth: 1,
324
+ borderColor: highlighted ? '#FF7900' : 'var(--color-border-default)',
325
+ backgroundColor: 'var(--color-surface-raised)',
326
+ }}
327
+ testID={`body-map-muscle-${entry.key}`}
328
+ >
329
+ <View
330
+ style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: dotColor }}
331
+ accessibilityElementsHidden
332
+ testID={`body-map-muscle-dot-${entry.key}`}
333
+ />
334
+ <Text
335
+ style={{
336
+ fontSize: 11,
337
+ fontFamily: 'Inter, sans-serif',
338
+ fontWeight: '500',
339
+ color: TEXT_PRIMARY,
340
+ }}
341
+ accessibilityElementsHidden
342
+ >
343
+ {entry.name}
344
+ </Text>
345
+ </Pressable>
346
+ )
347
+ }