@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,170 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { MesoCard } from './MesoCard'
5
+ import type { WeekRowProps } from './WeekRow'
6
+
7
+ const weeks: WeekRowProps[] = [
8
+ {
9
+ weekNumber: 1,
10
+ totalWeeks: 4,
11
+ workouts: [
12
+ { name: 'Upper', status: 'completed' },
13
+ { name: 'Lower', status: 'completed' },
14
+ ],
15
+ intensityLevel: 0.4,
16
+ },
17
+ {
18
+ weekNumber: 2,
19
+ totalWeeks: 4,
20
+ workouts: [
21
+ { name: 'Upper', status: 'current' },
22
+ { name: 'Lower', status: 'upcoming' },
23
+ ],
24
+ intensityLevel: 0.6,
25
+ },
26
+ ]
27
+
28
+ const baseProps = {
29
+ name: 'Accumulation',
30
+ goal: 'Hypertrophy',
31
+ split: 'Upper/Lower',
32
+ weekRange: 'Weeks 1-4',
33
+ totalWeeks: 4,
34
+ weeks,
35
+ }
36
+
37
+ describe('MesoCard', () => {
38
+ describe('rendering', () => {
39
+ it('renders name, goal badge, and sub-header', () => {
40
+ render(<MesoCard {...baseProps} />)
41
+ expect(screen.getByTestId('meso-card')).toBeInTheDocument()
42
+ expect(screen.getByTestId('meso-card-name')).toHaveTextContent(
43
+ 'Accumulation',
44
+ )
45
+ expect(screen.getByTestId('meso-card-goal-badge')).toHaveTextContent(
46
+ 'Hypertrophy',
47
+ )
48
+ expect(screen.getByTestId('meso-card-subheader')).toHaveTextContent(
49
+ 'Upper/Lower · Weeks 1-4',
50
+ )
51
+ })
52
+
53
+ it('renders the 3px gradient top accent', () => {
54
+ render(<MesoCard {...baseProps} />)
55
+ expect(screen.getByTestId('meso-card-accent')).toBeInTheDocument()
56
+ })
57
+ })
58
+
59
+ describe('expansion', () => {
60
+ it('does not render week list when collapsed', () => {
61
+ render(<MesoCard {...baseProps} />)
62
+ expect(screen.queryByTestId('meso-card-weeks')).not.toBeInTheDocument()
63
+ })
64
+
65
+ it('renders the week list when expanded', () => {
66
+ render(<MesoCard {...baseProps} expanded />)
67
+ expect(screen.getByTestId('meso-card-weeks')).toBeInTheDocument()
68
+ expect(screen.getAllByTestId('week-row')).toHaveLength(2)
69
+ })
70
+
71
+ it('marks header as a button and fires onToggle', () => {
72
+ const onToggle = vi.fn()
73
+ render(<MesoCard {...baseProps} onToggle={onToggle} />)
74
+ const toggle = screen.getByTestId('meso-card-toggle')
75
+ expect(toggle).toHaveAttribute('aria-expanded', 'false')
76
+ fireEvent.click(toggle)
77
+ expect(onToggle).toHaveBeenCalledOnce()
78
+ })
79
+
80
+ it('reflects expanded state via aria-expanded', () => {
81
+ const onToggle = vi.fn()
82
+ render(<MesoCard {...baseProps} onToggle={onToggle} expanded />)
83
+ expect(screen.getByTestId('meso-card-toggle')).toHaveAttribute(
84
+ 'aria-expanded',
85
+ 'true',
86
+ )
87
+ })
88
+
89
+ it('renders a static container when not expandable', () => {
90
+ render(<MesoCard {...baseProps} />)
91
+ expect(screen.getByTestId('meso-card-static')).toBeInTheDocument()
92
+ expect(screen.queryByTestId('meso-card-toggle')).not.toBeInTheDocument()
93
+ })
94
+
95
+ it('derives current week marker from currentWeek prop', () => {
96
+ render(<MesoCard {...baseProps} currentWeek={2} expanded />)
97
+ expect(screen.getByTestId('week-row-current-dot')).toBeInTheDocument()
98
+ })
99
+ })
100
+
101
+ describe('volume heatmap', () => {
102
+ it('renders a heatmap cell per muscle group', () => {
103
+ render(
104
+ <MesoCard
105
+ {...baseProps}
106
+ volumeHeatmap={[
107
+ { group: 'chest', percentage: 80 },
108
+ { group: 'back', percentage: 50 },
109
+ { group: 'legs', percentage: 30 },
110
+ ]}
111
+ />,
112
+ )
113
+ expect(screen.getByTestId('meso-card-heatmap')).toBeInTheDocument()
114
+ expect(screen.getAllByTestId('meso-card-heatmap-cell')).toHaveLength(3)
115
+ })
116
+
117
+ it('does not render heatmap when no data provided', () => {
118
+ render(<MesoCard {...baseProps} />)
119
+ expect(screen.queryByTestId('meso-card-heatmap')).not.toBeInTheDocument()
120
+ })
121
+ })
122
+
123
+ describe('highlighted state', () => {
124
+ it('applies a brand-primary border color when highlighted', () => {
125
+ render(<MesoCard {...baseProps} highlighted />)
126
+ const card = screen.getByTestId('meso-card')
127
+ const style = card.getAttribute('style') ?? ''
128
+ expect(style).toContain('box-shadow')
129
+ })
130
+ })
131
+
132
+ describe('accessibility', () => {
133
+ it('has an accessible label describing the meso', () => {
134
+ render(<MesoCard {...baseProps} />)
135
+ expect(
136
+ screen.getByLabelText('Accumulation, Hypertrophy, Weeks 1-4'),
137
+ ).toBeInTheDocument()
138
+ })
139
+
140
+ it('exposes the label on the button when expandable', () => {
141
+ render(<MesoCard {...baseProps} onToggle={vi.fn()} />)
142
+ const toggle = screen.getByLabelText('Accumulation, Hypertrophy, Weeks 1-4')
143
+ expect(toggle).toHaveAttribute('role', 'button')
144
+ })
145
+
146
+ it('has no accessibility violations', async () => {
147
+ const { container } = render(<MesoCard {...baseProps} />)
148
+ const results = await axe(container)
149
+ expect(results).toHaveNoViolations()
150
+ })
151
+
152
+ it('has no accessibility violations when expanded with all features', async () => {
153
+ const { container } = render(
154
+ <MesoCard
155
+ {...baseProps}
156
+ currentWeek={2}
157
+ onToggle={vi.fn()}
158
+ expanded
159
+ highlighted
160
+ volumeHeatmap={[
161
+ { group: 'chest', percentage: 80 },
162
+ { group: 'back', percentage: 50 },
163
+ ]}
164
+ />,
165
+ )
166
+ const results = await axe(container)
167
+ expect(results).toHaveNoViolations()
168
+ })
169
+ })
170
+ })
@@ -0,0 +1,235 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { Card } from '../../ui/card'
5
+ import { Badge } from '../../ui/badge'
6
+ import { WeekRow, type WeekRowProps } from './WeekRow'
7
+
8
+ const BRAND_PRIMARY = '#FF7900'
9
+ const BRAND_PRIMARY_DARK = '#C45E00'
10
+ const BRAND_PRIMARY_LIGHT = '#FFB066'
11
+ const BORDER_DEFAULT = '#1F1F1F'
12
+ const TEXT_PRIMARY = '#F3F4F6'
13
+ const TEXT_SECONDARY = '#9CA3AF'
14
+
15
+ /** Gradient stops for the 3px top accent: dark -> primary -> light. */
16
+ const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
17
+
18
+ export interface MesoVolumeHeatmapEntry {
19
+ /** Muscle group identifier (free-form to match plan data). */
20
+ group: string
21
+ /** Planned volume intensity for the group, 0-100. */
22
+ percentage: number
23
+ }
24
+
25
+ export interface MesoCardProps extends ViewProps {
26
+ /** Mesocycle name, e.g. "Accumulation". */
27
+ name: string
28
+ /** Training goal, e.g. "Hypertrophy", "Strength", "Peaking". */
29
+ goal: string
30
+ /** Training split, e.g. "Upper/Lower", "Push/Pull/Legs". */
31
+ split: string
32
+ /** Week range label, e.g. "Weeks 1-4". */
33
+ weekRange: string
34
+ /** Current week within the mesocycle (1-based). */
35
+ currentWeek?: number
36
+ /** Total weeks in the mesocycle. */
37
+ totalWeeks: number
38
+ /** Weeks rendered as a WeekRow list when expanded. */
39
+ weeks: WeekRowProps[]
40
+ /** Whether the card is expanded to reveal the week list. */
41
+ expanded?: boolean
42
+ /** Toggles expansion; makes the header a button when provided. */
43
+ onToggle?: () => void
44
+ /** Whether this meso is highlighted in the MesoProgressBar. */
45
+ highlighted?: boolean
46
+ /** Volume heatmap data per muscle group for the meso. */
47
+ volumeHeatmap?: MesoVolumeHeatmapEntry[]
48
+ className?: string
49
+ }
50
+
51
+ /**
52
+ * Maps a 0-100 volume percentage onto a brand-primary tint whose opacity
53
+ * tracks intensity, keeping a visible floor so low-volume groups stay legible.
54
+ */
55
+ function heatmapColor(percentage: number): string {
56
+ const clamped = Math.max(0, Math.min(100, percentage))
57
+ const opacity = 0.12 + (clamped / 100) * 0.78
58
+ return `rgba(255,121,0,${opacity.toFixed(3)})`
59
+ }
60
+
61
+ /**
62
+ * A mesocycle card with name, goal, split, week range, an optional volume
63
+ * heatmap strip, and an expandable WeekRow list. Highlighting animates the
64
+ * border toward brand-primary with a subtle glow to stay in sync with the
65
+ * MesoProgressBar.
66
+ *
67
+ * @example
68
+ * <MesoCard
69
+ * name="Accumulation"
70
+ * goal="Hypertrophy"
71
+ * split="Upper/Lower"
72
+ * weekRange="Weeks 1-4"
73
+ * currentWeek={2}
74
+ * totalWeeks={4}
75
+ * weeks={weeks}
76
+ * volumeHeatmap={[{ group: 'chest', percentage: 80 }]}
77
+ * expanded
78
+ * onToggle={() => {}}
79
+ * highlighted
80
+ * />
81
+ */
82
+ export function MesoCard({
83
+ name,
84
+ goal,
85
+ split,
86
+ weekRange,
87
+ currentWeek,
88
+ totalWeeks,
89
+ weeks,
90
+ expanded = false,
91
+ onToggle,
92
+ highlighted = false,
93
+ volumeHeatmap,
94
+ className,
95
+ ...props
96
+ }: MesoCardProps) {
97
+ const isExpandable = onToggle != null
98
+ const [highlight] = useState(() => new Animated.Value(highlighted ? 1 : 0))
99
+
100
+ useEffect(() => {
101
+ const animation = Animated.timing(highlight, {
102
+ toValue: highlighted ? 1 : 0,
103
+ duration: 250,
104
+ easing: Easing.out(Easing.ease),
105
+ useNativeDriver: false,
106
+ })
107
+ animation.start()
108
+ return () => animation.stop()
109
+ }, [highlighted, highlight])
110
+
111
+ const borderColor = highlight.interpolate({
112
+ inputRange: [0, 1],
113
+ outputRange: [BORDER_DEFAULT, BRAND_PRIMARY],
114
+ })
115
+
116
+ const header = (
117
+ <View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 10 }} testID="meso-card-body">
118
+ <View className="flex-row items-center" style={{ gap: 8 }} testID="meso-card-header">
119
+ <Text
120
+ style={{
121
+ fontSize: 15,
122
+ fontFamily: '"Space Grotesk", sans-serif',
123
+ fontWeight: '700',
124
+ color: TEXT_PRIMARY,
125
+ }}
126
+ testID="meso-card-name"
127
+ >
128
+ {name}
129
+ </Text>
130
+ <Badge variant="subtle" color="primary" size="sm" testID="meso-card-goal-badge">
131
+ {goal}
132
+ </Badge>
133
+ </View>
134
+
135
+ <Text
136
+ style={{
137
+ marginTop: 4,
138
+ fontSize: 12,
139
+ fontFamily: 'Inter, sans-serif',
140
+ color: TEXT_SECONDARY,
141
+ }}
142
+ testID="meso-card-subheader"
143
+ >
144
+ {`${split} · ${weekRange}`}
145
+ </Text>
146
+
147
+ {volumeHeatmap && volumeHeatmap.length > 0 && (
148
+ <View
149
+ className="flex-row items-center"
150
+ style={{ marginTop: 10, gap: 3 }}
151
+ accessibilityElementsHidden
152
+ testID="meso-card-heatmap"
153
+ >
154
+ {volumeHeatmap.map((entry) => (
155
+ <View
156
+ key={entry.group}
157
+ style={{
158
+ flex: 1,
159
+ height: 8,
160
+ borderRadius: 2,
161
+ backgroundColor: heatmapColor(entry.percentage),
162
+ }}
163
+ testID="meso-card-heatmap-cell"
164
+ />
165
+ ))}
166
+ </View>
167
+ )}
168
+ </View>
169
+ )
170
+
171
+ return (
172
+ <Animated.View style={highlighted ? { transform: [{ scale: 1.0 }] } : undefined} testID="meso-card-wrapper">
173
+ <Card
174
+ variant="outline"
175
+ elevation={2}
176
+ borderColor={highlighted ? BRAND_PRIMARY : BORDER_DEFAULT}
177
+ className={className}
178
+ style={[
179
+ { borderColor: borderColor as unknown as string },
180
+ highlighted
181
+ ? { boxShadow: '0 0 12px 2px rgba(255,121,0,0.25)' }
182
+ : undefined,
183
+ ]}
184
+ testID="meso-card"
185
+ {...props}
186
+ >
187
+ <View
188
+ className="flex-row"
189
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, zIndex: 1 }}
190
+ accessibilityElementsHidden
191
+ testID="meso-card-accent"
192
+ >
193
+ {ACCENT_STOPS.map((color) => (
194
+ <View key={color} style={{ flex: 1, backgroundColor: color }} />
195
+ ))}
196
+ </View>
197
+
198
+ {isExpandable ? (
199
+ <Pressable
200
+ onPress={onToggle}
201
+ accessibilityRole="button"
202
+ accessibilityLabel={`${name}, ${goal}, ${weekRange}`}
203
+ aria-expanded={expanded}
204
+ testID="meso-card-toggle"
205
+ >
206
+ {header}
207
+ </Pressable>
208
+ ) : (
209
+ <View
210
+ accessibilityLabel={`${name}, ${goal}, ${weekRange}`}
211
+ testID="meso-card-static"
212
+ >
213
+ {header}
214
+ </View>
215
+ )}
216
+
217
+ {expanded && weeks.length > 0 && (
218
+ <View
219
+ style={{ borderTopWidth: 1, borderTopColor: BORDER_DEFAULT }}
220
+ testID="meso-card-weeks"
221
+ >
222
+ {weeks.map((week) => (
223
+ <WeekRow
224
+ key={week.weekNumber}
225
+ {...week}
226
+ totalWeeks={week.totalWeeks ?? totalWeeks}
227
+ isCurrent={week.isCurrent ?? week.weekNumber === currentWeek}
228
+ />
229
+ ))}
230
+ </View>
231
+ )}
232
+ </Card>
233
+ </Animated.View>
234
+ )
235
+ }
@@ -0,0 +1,85 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View } from 'react-native'
4
+ import { MesoProgressBar, type Meso } from './MesoProgressBar'
5
+
6
+ const meta: Meta<typeof MesoProgressBar> = {
7
+ title: 'Custom/Workout/MesoProgressBar',
8
+ component: MesoProgressBar,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ activeMesoId: {
12
+ control: 'text',
13
+ description: 'Id of the highlighted meso, synced with MesoCard selection',
14
+ },
15
+ },
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof MesoProgressBar>
20
+
21
+ const sampleMesos: Meso[] = [
22
+ { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
23
+ { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
24
+ { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
25
+ { id: 'm4', name: 'Deload', weekCount: 1, status: 'upcoming' },
26
+ ]
27
+
28
+ export const Default: Story = {
29
+ args: {
30
+ mesos: sampleMesos,
31
+ activeMesoId: null,
32
+ onMesoPress: () => {},
33
+ },
34
+ render: (args) => (
35
+ <View style={{ paddingVertical: 24, width: 480 }}>
36
+ <MesoProgressBar {...args} />
37
+ </View>
38
+ ),
39
+ }
40
+
41
+ export const ActiveCurrent: Story = {
42
+ args: {
43
+ mesos: sampleMesos,
44
+ activeMesoId: 'm2',
45
+ onMesoPress: () => {},
46
+ },
47
+ render: (args) => (
48
+ <View style={{ paddingVertical: 24, width: 480 }}>
49
+ <MesoProgressBar {...args} />
50
+ </View>
51
+ ),
52
+ }
53
+
54
+ export const FirstWeekProgress: Story = {
55
+ args: {
56
+ mesos: [
57
+ { id: 'a', name: 'Base', weekCount: 5, status: 'current', currentWeek: 1 },
58
+ { id: 'b', name: 'Build', weekCount: 4, status: 'upcoming' },
59
+ ],
60
+ activeMesoId: 'a',
61
+ onMesoPress: () => {},
62
+ },
63
+ render: (args) => (
64
+ <View style={{ paddingVertical: 24, width: 480 }}>
65
+ <MesoProgressBar {...args} />
66
+ </View>
67
+ ),
68
+ }
69
+
70
+ function InteractiveDemo() {
71
+ const [activeMesoId, setActiveMesoId] = useState<string | null>('m2')
72
+ return (
73
+ <View style={{ paddingVertical: 24, width: 480 }}>
74
+ <MesoProgressBar
75
+ mesos={sampleMesos}
76
+ activeMesoId={activeMesoId}
77
+ onMesoPress={setActiveMesoId}
78
+ />
79
+ </View>
80
+ )
81
+ }
82
+
83
+ export const Interactive: Story = {
84
+ render: () => <InteractiveDemo />,
85
+ }
@@ -0,0 +1,141 @@
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 { MesoProgressBar, type Meso } from './MesoProgressBar'
5
+
6
+ const mesos: Meso[] = [
7
+ { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
8
+ { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
9
+ { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
10
+ ]
11
+
12
+ describe('MesoProgressBar', () => {
13
+ describe('rendering', () => {
14
+ it('renders the container', () => {
15
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
16
+ expect(screen.getByTestId('meso-progress-bar')).toBeInTheDocument()
17
+ })
18
+
19
+ it('renders one segment per meso', () => {
20
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
21
+ expect(screen.getByTestId('meso-segment-m1')).toBeInTheDocument()
22
+ expect(screen.getByTestId('meso-segment-m2')).toBeInTheDocument()
23
+ expect(screen.getByTestId('meso-segment-m3')).toBeInTheDocument()
24
+ })
25
+
26
+ it('sets flex proportional to weekCount', () => {
27
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
28
+ expect(screen.getByTestId('meso-segment-m1')).toHaveStyle({ flex: 4 })
29
+ expect(screen.getByTestId('meso-segment-m2')).toHaveStyle({ flex: 3 })
30
+ expect(screen.getByTestId('meso-segment-m3')).toHaveStyle({ flex: 2 })
31
+ })
32
+
33
+ it('renders no segments for an empty mesos array', () => {
34
+ render(<MesoProgressBar mesos={[]} activeMesoId={null} onMesoPress={vi.fn()} />)
35
+ expect(screen.getByTestId('meso-progress-bar')).toBeEmptyDOMElement()
36
+ })
37
+ })
38
+
39
+ describe('status colors', () => {
40
+ it('applies completed color at 0.3 alpha', () => {
41
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
42
+ expect(screen.getByTestId('meso-segment-inner-m1')).toHaveStyle({
43
+ backgroundColor: 'rgba(20,184,166,0.3)',
44
+ })
45
+ })
46
+
47
+ it('applies current color at 0.5 alpha', () => {
48
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
49
+ expect(screen.getByTestId('meso-segment-inner-m2')).toHaveStyle({
50
+ backgroundColor: 'rgba(255,121,0,0.5)',
51
+ })
52
+ })
53
+
54
+ it('applies upcoming color', () => {
55
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
56
+ expect(screen.getByTestId('meso-segment-inner-m3')).toHaveStyle({
57
+ backgroundColor: 'rgba(255,255,255,0.06)',
58
+ })
59
+ })
60
+ })
61
+
62
+ describe('current week progress fill', () => {
63
+ it('renders a solid fill for the current meso when progress > 0', () => {
64
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
65
+ const fill = screen.getByTestId('meso-segment-fill-m2')
66
+ expect(fill).toBeInTheDocument()
67
+ expect(fill).toHaveStyle({ width: '66.66666666666666%' })
68
+ })
69
+
70
+ it('does not render a fill for completed or upcoming mesos', () => {
71
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
72
+ expect(screen.queryByTestId('meso-segment-fill-m1')).not.toBeInTheDocument()
73
+ expect(screen.queryByTestId('meso-segment-fill-m3')).not.toBeInTheDocument()
74
+ })
75
+
76
+ it('does not render a fill when current meso has no currentWeek', () => {
77
+ const noProgress: Meso[] = [
78
+ { id: 'c', name: 'Current', weekCount: 3, status: 'current' },
79
+ ]
80
+ render(<MesoProgressBar mesos={noProgress} activeMesoId={null} onMesoPress={vi.fn()} />)
81
+ expect(screen.queryByTestId('meso-segment-fill-c')).not.toBeInTheDocument()
82
+ })
83
+
84
+ it('clamps progress to 100% when currentWeek exceeds weekCount', () => {
85
+ const overflow: Meso[] = [
86
+ { id: 'c', name: 'Current', weekCount: 3, status: 'current', currentWeek: 5 },
87
+ ]
88
+ render(<MesoProgressBar mesos={overflow} activeMesoId={null} onMesoPress={vi.fn()} />)
89
+ expect(screen.getByTestId('meso-segment-fill-c')).toHaveStyle({ width: '100%' })
90
+ })
91
+ })
92
+
93
+ describe('active highlighting', () => {
94
+ it('applies a brand border to the active segment', () => {
95
+ render(<MesoProgressBar mesos={mesos} activeMesoId="m2" onMesoPress={vi.fn()} />)
96
+ const style = screen.getByTestId('meso-segment-inner-m2').getAttribute('style') ?? ''
97
+ expect(style).toContain('border-top-width: 2px')
98
+ expect(style).toContain('border-top-color: rgb(255, 121, 0)')
99
+ })
100
+
101
+ it('does not apply a border to inactive segments', () => {
102
+ render(<MesoProgressBar mesos={mesos} activeMesoId="m2" onMesoPress={vi.fn()} />)
103
+ const inner = screen.getByTestId('meso-segment-inner-m1')
104
+ const style = inner.getAttribute('style') ?? ''
105
+ expect(style).not.toContain('border-top-width: 2px')
106
+ })
107
+
108
+ it('highlights nothing when activeMesoId is null', () => {
109
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
110
+ const inner = screen.getByTestId('meso-segment-inner-m2')
111
+ const style = inner.getAttribute('style') ?? ''
112
+ expect(style).not.toContain('border-top-width: 2px')
113
+ })
114
+ })
115
+
116
+ describe('interaction', () => {
117
+ it('calls onMesoPress with the meso id when a segment is pressed', () => {
118
+ const onMesoPress = vi.fn()
119
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={onMesoPress} />)
120
+ fireEvent.click(screen.getByTestId('meso-segment-m3'))
121
+ expect(onMesoPress).toHaveBeenCalledWith('m3')
122
+ })
123
+ })
124
+
125
+ describe('accessibility', () => {
126
+ it('exposes each segment as a button with name and status', () => {
127
+ render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
128
+ expect(screen.getByLabelText('Accumulation, completed')).toBeInTheDocument()
129
+ expect(screen.getByLabelText('Intensification, current')).toBeInTheDocument()
130
+ expect(screen.getByLabelText('Peak, upcoming')).toBeInTheDocument()
131
+ })
132
+
133
+ it('has no accessibility violations', async () => {
134
+ const { container } = render(
135
+ <MesoProgressBar mesos={mesos} activeMesoId="m2" onMesoPress={vi.fn()} />,
136
+ )
137
+ const results = await axe(container)
138
+ expect(results).toHaveNoViolations()
139
+ })
140
+ })
141
+ })