@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,78 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, type ViewProps, type ViewStyle } from 'react-native'
3
+
4
+ export interface DeviationBarProps extends ViewProps {
5
+ deviation: number
6
+ width?: number
7
+ className?: string
8
+ }
9
+
10
+ function getDotColor(deviation: number): string {
11
+ const abs = Math.abs(deviation)
12
+ if (deviation < -0.3) return '#14B8A6'
13
+ if (abs <= 0.3) return '#6B7280'
14
+ if (abs <= 0.7) return '#FFB020'
15
+ return '#D14343'
16
+ }
17
+
18
+ function getDeviationDescription(deviation: number): string {
19
+ if (deviation < -0.3) return 'lighter than planned'
20
+ if (deviation > 0.3) return 'harder than planned'
21
+ return 'on plan'
22
+ }
23
+
24
+ export function DeviationBar({
25
+ deviation,
26
+ width,
27
+ className,
28
+ ...props
29
+ }: DeviationBarProps) {
30
+ const clamped = Math.max(-1, Math.min(1, deviation))
31
+ const resolvedWidth = width ?? 40
32
+ const dotPosition = ((clamped + 1) / 2) * resolvedWidth
33
+ const dotSize = 8
34
+ const trackHeight = 6
35
+ const containerHeight = 10
36
+ const valueNow = Math.round(clamped * 100)
37
+
38
+ return (
39
+ <View
40
+ className={className}
41
+ style={{ flexDirection: 'row', alignItems: 'center', height: containerHeight, width: resolvedWidth }}
42
+ accessibilityRole="adjustable"
43
+ accessibilityValue={{ min: -100, max: 100, now: valueNow }}
44
+ accessibilityLabel={`Session deviation: ${getDeviationDescription(clamped)}`}
45
+ testID="deviation-bar"
46
+ aria-valuenow={valueNow}
47
+ aria-valuemin={-100}
48
+ aria-valuemax={100}
49
+ {...props}
50
+ >
51
+ <View
52
+ style={{
53
+ height: trackHeight,
54
+ width: resolvedWidth,
55
+ borderRadius: 3,
56
+ // react-native-web renders backgroundImage at runtime; not in RN ViewStyle types
57
+ backgroundImage:
58
+ 'linear-gradient(90deg, rgba(20,184,166,0.25) 0%, rgba(107,114,128,0.15) 50%, rgba(255,176,32,0.25) 100%)',
59
+ } as ViewStyle}
60
+ />
61
+ <View
62
+ style={{
63
+ position: 'absolute',
64
+ width: dotSize,
65
+ height: dotSize,
66
+ borderRadius: 9999,
67
+ borderWidth: 1.5,
68
+ borderColor: '#F3F4F6',
69
+ boxShadow: '0 0 4px rgba(0,0,0,0.5)',
70
+ backgroundColor: getDotColor(clamped),
71
+ left: Math.max(0, Math.min(dotPosition - dotSize / 2, resolvedWidth - dotSize)),
72
+ top: (containerHeight - dotSize) / 2,
73
+ }}
74
+ testID="deviation-dot"
75
+ />
76
+ </View>
77
+ )
78
+ }
@@ -0,0 +1,186 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { ExerciseCard } from './ExerciseCard'
4
+ import type { SetRowProps } from './SetRow'
5
+
6
+ const meta: Meta<typeof ExerciseCard> = {
7
+ title: 'Custom/Workout/ExerciseCard',
8
+ component: ExerciseCard,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ state: {
12
+ control: 'select',
13
+ options: ['collapsed', 'expanded', 'upcoming'],
14
+ description: 'Card display state',
15
+ },
16
+ },
17
+ decorators: [
18
+ (Story) => (
19
+ <View style={{ maxWidth: 400, padding: 16, backgroundColor: '#121212' }}>
20
+ <Story />
21
+ </View>
22
+ ),
23
+ ],
24
+ }
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof ExerciseCard>
28
+
29
+ const sampleSets: SetRowProps[] = [
30
+ {
31
+ mode: 'completed',
32
+ setNumber: 1,
33
+ reps: 8,
34
+ weight: 135,
35
+ unit: 'lbs',
36
+ rpe: 7,
37
+ previous: { reps: 8, weight: 130 },
38
+ velocities: [1.1, 0.95, 0.82, 0.68, 0.55, 0.48, 0.42, 0.38],
39
+ },
40
+ {
41
+ mode: 'completed',
42
+ setNumber: 2,
43
+ reps: 8,
44
+ weight: 135,
45
+ unit: 'lbs',
46
+ rpe: 8.5,
47
+ previous: { reps: 8, weight: 135 },
48
+ velocities: [0.95, 0.88, 0.78, 0.65, 0.52, 0.45, 0.40, 0.35],
49
+ },
50
+ {
51
+ mode: 'active',
52
+ setNumber: 3,
53
+ reps: null,
54
+ weight: null,
55
+ unit: 'lbs',
56
+ isNextSet: true,
57
+ targets: { reps: 8, weight: 135 },
58
+ previous: { reps: 8, weight: 135 },
59
+ },
60
+ {
61
+ mode: 'active',
62
+ setNumber: 4,
63
+ reps: null,
64
+ weight: null,
65
+ unit: 'lbs',
66
+ targets: { reps: 8, weight: 135 },
67
+ previous: { reps: 8, weight: 135 },
68
+ },
69
+ ]
70
+
71
+ export const CollapsedWithVelocity: Story = {
72
+ args: {
73
+ name: 'Bench Press',
74
+ state: 'collapsed',
75
+ onToggle: () => {},
76
+ summary: { sets: 4, reps: 8, weight: 135, unit: 'lbs' },
77
+ setVelocities: [
78
+ [1.1, 0.95, 0.82, 0.68, 0.55, 0.48, 0.42, 0.38],
79
+ [0.95, 0.88, 0.78, 0.65, 0.52, 0.45, 0.40, 0.35],
80
+ ],
81
+ totalPlannedSets: 4,
82
+ e1rm: { value: 225, unit: 'lbs' },
83
+ },
84
+ }
85
+
86
+ export const CollapsedWithPR: Story = {
87
+ args: {
88
+ name: 'Squat',
89
+ state: 'collapsed',
90
+ onToggle: () => {},
91
+ summary: { sets: 3, reps: 5, weight: 275, unit: 'lbs' },
92
+ setVelocities: [
93
+ [0.65, 0.55, 0.45, 0.38, 0.32],
94
+ [0.60, 0.50, 0.42, 0.35, 0.30],
95
+ [0.58, 0.48, 0.40, 0.33, 0.28],
96
+ ],
97
+ totalPlannedSets: 3,
98
+ e1rm: { value: 365, unit: 'lbs' },
99
+ isPR: true,
100
+ },
101
+ }
102
+
103
+ export const ExpandedWithSets: Story = {
104
+ args: {
105
+ name: 'Bench Press',
106
+ state: 'expanded',
107
+ onToggle: () => {},
108
+ summary: { sets: 4, reps: 8, weight: 135, unit: 'lbs' },
109
+ sets: sampleSets,
110
+ e1rm: { value: 225, unit: 'lbs' },
111
+ },
112
+ }
113
+
114
+ export const ExpandedWithTempo: Story = {
115
+ args: {
116
+ name: 'Romanian Deadlift',
117
+ state: 'expanded',
118
+ onToggle: () => {},
119
+ summary: { sets: 3, reps: 10, weight: 185, unit: 'lbs' },
120
+ tempo: [2, 1, 3, 0],
121
+ sets: [
122
+ {
123
+ mode: 'completed',
124
+ setNumber: 1,
125
+ reps: 10,
126
+ weight: 185,
127
+ unit: 'lbs',
128
+ rpe: 7,
129
+ previous: { reps: 10, weight: 175 },
130
+ },
131
+ {
132
+ mode: 'active',
133
+ setNumber: 2,
134
+ reps: null,
135
+ weight: null,
136
+ unit: 'lbs',
137
+ isNextSet: true,
138
+ targets: { reps: 10, weight: 185 },
139
+ previous: { reps: 10, weight: 185 },
140
+ },
141
+ {
142
+ mode: 'active',
143
+ setNumber: 3,
144
+ reps: null,
145
+ weight: null,
146
+ unit: 'lbs',
147
+ targets: { reps: 10, weight: 185 },
148
+ previous: { reps: 10, weight: 185 },
149
+ },
150
+ ],
151
+ },
152
+ }
153
+
154
+ export const Upcoming: Story = {
155
+ args: {
156
+ name: 'Lat Pulldown',
157
+ state: 'upcoming',
158
+ onToggle: () => {},
159
+ prescription: '3\u00D78-12 @ RPE 8',
160
+ previousBest: '135 lbs \u00D7 10',
161
+ },
162
+ }
163
+
164
+ export const SupersetFirst: Story = {
165
+ args: {
166
+ name: 'Bench Press',
167
+ state: 'collapsed',
168
+ onToggle: () => {},
169
+ summary: { sets: 3, reps: 8, weight: 185, unit: 'lbs' },
170
+ supersetPosition: 'first',
171
+ setVelocities: [[1.0, 0.9, 0.8]],
172
+ totalPlannedSets: 3,
173
+ },
174
+ }
175
+
176
+ export const SupersetLast: Story = {
177
+ args: {
178
+ name: 'Barbell Row',
179
+ state: 'collapsed',
180
+ onToggle: () => {},
181
+ summary: { sets: 3, reps: 8, weight: 155, unit: 'lbs' },
182
+ supersetPosition: 'last',
183
+ setVelocities: [[0.9, 0.8, 0.7]],
184
+ totalPlannedSets: 3,
185
+ },
186
+ }
@@ -0,0 +1,319 @@
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 { ExerciseCard } from './ExerciseCard'
5
+ import type { SetRowProps } from './SetRow'
6
+
7
+ const baseCollapsedProps = {
8
+ name: 'Bench Press',
9
+ state: 'collapsed' as const,
10
+ onToggle: vi.fn(),
11
+ summary: { sets: 3, reps: 6, weight: 175, unit: 'lbs' as const },
12
+ }
13
+
14
+ const baseSets: SetRowProps[] = [
15
+ { mode: 'completed', setNumber: 1, reps: 8, weight: 135, unit: 'lbs' },
16
+ { mode: 'completed', setNumber: 2, reps: 8, weight: 135, unit: 'lbs' },
17
+ { mode: 'active', setNumber: 3, reps: null, weight: null, unit: 'lbs', isNextSet: true },
18
+ ]
19
+
20
+ describe('ExerciseCard', () => {
21
+ describe('collapsed state', () => {
22
+ it('renders exercise name', () => {
23
+ render(<ExerciseCard {...baseCollapsedProps} />)
24
+ expect(screen.getByTestId('exercise-card-name')).toHaveTextContent('Bench Press')
25
+ })
26
+
27
+ it('renders summary text', () => {
28
+ render(<ExerciseCard {...baseCollapsedProps} />)
29
+ expect(screen.getByTestId('exercise-card-summary')).toHaveTextContent(
30
+ '3\u00D76 @ 175 lbs',
31
+ )
32
+ })
33
+
34
+ it('renders WeightBadge when e1rm provided', () => {
35
+ render(
36
+ <ExerciseCard
37
+ {...baseCollapsedProps}
38
+ e1rm={{ value: 225, unit: 'lbs' }}
39
+ />,
40
+ )
41
+ expect(screen.getByTestId('exercise-card-e1rm')).toBeInTheDocument()
42
+ })
43
+
44
+ it('renders PrBadge when isPR is true', () => {
45
+ render(<ExerciseCard {...baseCollapsedProps} isPR />)
46
+ expect(screen.getByTestId('pr-badge-star')).toBeInTheDocument()
47
+ })
48
+
49
+ it('renders mini velocity strips for completed sets', () => {
50
+ render(
51
+ <ExerciseCard
52
+ {...baseCollapsedProps}
53
+ setVelocities={[[1.1, 0.95], [0.9, 0.8]]}
54
+ totalPlannedSets={4}
55
+ />,
56
+ )
57
+ expect(screen.getByTestId('exercise-card-velocity-strip-0')).toBeInTheDocument()
58
+ expect(screen.getByTestId('exercise-card-velocity-strip-1')).toBeInTheDocument()
59
+ })
60
+
61
+ it('renders placeholder strips for remaining planned sets', () => {
62
+ render(
63
+ <ExerciseCard
64
+ {...baseCollapsedProps}
65
+ setVelocities={[[1.1, 0.95]]}
66
+ totalPlannedSets={3}
67
+ />,
68
+ )
69
+ expect(screen.getByTestId('exercise-card-placeholder-0')).toBeInTheDocument()
70
+ expect(screen.getByTestId('exercise-card-placeholder-1')).toBeInTheDocument()
71
+ })
72
+
73
+ it('does not render strips container when no velocities and no planned sets', () => {
74
+ render(<ExerciseCard {...baseCollapsedProps} />)
75
+ expect(screen.queryByTestId('exercise-card-strips')).not.toBeInTheDocument()
76
+ })
77
+ })
78
+
79
+ describe('expanded state', () => {
80
+ const expandedProps = {
81
+ name: 'Squat',
82
+ state: 'expanded' as const,
83
+ onToggle: vi.fn(),
84
+ summary: { sets: 3, reps: 8, weight: 185, unit: 'lbs' as const },
85
+ sets: baseSets,
86
+ }
87
+
88
+ it('renders exercise name in header', () => {
89
+ render(<ExerciseCard {...expandedProps} />)
90
+ expect(screen.getByTestId('exercise-card-name')).toHaveTextContent('Squat')
91
+ })
92
+
93
+ it('renders column headers', () => {
94
+ render(<ExerciseCard {...expandedProps} />)
95
+ const headers = screen.getByTestId('exercise-card-column-headers')
96
+ expect(headers).toHaveTextContent('SET')
97
+ expect(headers).toHaveTextContent('PREV')
98
+ expect(headers).toHaveTextContent('REPS')
99
+ expect(headers).toHaveTextContent('LBS')
100
+ expect(headers).toHaveTextContent('RPE')
101
+ })
102
+
103
+ it('renders SetRow components', () => {
104
+ render(<ExerciseCard {...expandedProps} />)
105
+ const setRows = screen.getAllByTestId('set-row')
106
+ expect(setRows).toHaveLength(3)
107
+ })
108
+
109
+ it('renders TempoDisplay when tempo provided', () => {
110
+ render(
111
+ <ExerciseCard
112
+ {...expandedProps}
113
+ tempo={[2, 1, 3, 0]}
114
+ />,
115
+ )
116
+ expect(screen.getByTestId('exercise-card-tempo')).toBeInTheDocument()
117
+ expect(screen.getByTestId('tempo-display')).toBeInTheDocument()
118
+ })
119
+
120
+ it('does not render TempoDisplay when no tempo', () => {
121
+ render(<ExerciseCard {...expandedProps} />)
122
+ expect(screen.queryByTestId('exercise-card-tempo')).not.toBeInTheDocument()
123
+ })
124
+
125
+ it('sets aria-expanded on header button', () => {
126
+ render(<ExerciseCard {...expandedProps} />)
127
+ const header = screen.getByTestId('exercise-card-header')
128
+ expect(header).toHaveAttribute('aria-expanded', 'true')
129
+ })
130
+ })
131
+
132
+ describe('upcoming state', () => {
133
+ const upcomingProps = {
134
+ name: 'Deadlift',
135
+ state: 'upcoming' as const,
136
+ onToggle: vi.fn(),
137
+ prescription: '3\u00D78-12 @ RPE 8',
138
+ previousBest: '185 lbs \u00D7 10',
139
+ }
140
+
141
+ it('renders with reduced opacity', () => {
142
+ render(<ExerciseCard {...upcomingProps} />)
143
+ const card = screen.getByTestId('exercise-card')
144
+ expect(card).toHaveStyle({ opacity: 0.6 })
145
+ })
146
+
147
+ it('renders exercise name', () => {
148
+ render(<ExerciseCard {...upcomingProps} />)
149
+ expect(screen.getByTestId('exercise-card-name')).toHaveTextContent('Deadlift')
150
+ })
151
+
152
+ it('renders prescription text', () => {
153
+ render(<ExerciseCard {...upcomingProps} />)
154
+ expect(screen.getByTestId('exercise-card-prescription')).toHaveTextContent(
155
+ '3\u00D78-12 @ RPE 8',
156
+ )
157
+ })
158
+
159
+ it('renders previous best text', () => {
160
+ render(<ExerciseCard {...upcomingProps} />)
161
+ expect(screen.getByTestId('exercise-card-previous-best')).toHaveTextContent(
162
+ '185 lbs \u00D7 10',
163
+ )
164
+ })
165
+
166
+ it('does not render interactive elements', () => {
167
+ render(<ExerciseCard {...upcomingProps} />)
168
+ expect(screen.queryByTestId('exercise-card-strips')).not.toBeInTheDocument()
169
+ expect(screen.queryByTestId('exercise-card-column-headers')).not.toBeInTheDocument()
170
+ })
171
+ })
172
+
173
+ describe('onToggle', () => {
174
+ it('fires onToggle when collapsed card is pressed', () => {
175
+ const onToggle = vi.fn()
176
+ render(
177
+ <ExerciseCard
178
+ {...baseCollapsedProps}
179
+ onToggle={onToggle}
180
+ />,
181
+ )
182
+ fireEvent.click(screen.getByTestId('exercise-card-header'))
183
+ expect(onToggle).toHaveBeenCalledOnce()
184
+ })
185
+
186
+ it('fires onToggle when expanded header is pressed', () => {
187
+ const onToggle = vi.fn()
188
+ render(
189
+ <ExerciseCard
190
+ name="Squat"
191
+ state="expanded"
192
+ onToggle={onToggle}
193
+ sets={baseSets}
194
+ />,
195
+ )
196
+ fireEvent.click(screen.getByTestId('exercise-card-header'))
197
+ expect(onToggle).toHaveBeenCalledOnce()
198
+ })
199
+ })
200
+
201
+ describe('superset position border radius', () => {
202
+ it('applies first position border radius', () => {
203
+ render(
204
+ <ExerciseCard
205
+ {...baseCollapsedProps}
206
+ supersetPosition="first"
207
+ />,
208
+ )
209
+ const card = screen.getByTestId('exercise-card')
210
+ const container = card.firstChild as HTMLElement
211
+ expect(container).toHaveStyle({
212
+ borderTopLeftRadius: '12px',
213
+ borderTopRightRadius: '12px',
214
+ borderBottomLeftRadius: '8px',
215
+ borderBottomRightRadius: '8px',
216
+ })
217
+ })
218
+
219
+ it('applies last position border radius', () => {
220
+ render(
221
+ <ExerciseCard
222
+ {...baseCollapsedProps}
223
+ supersetPosition="last"
224
+ />,
225
+ )
226
+ const card = screen.getByTestId('exercise-card')
227
+ const container = card.firstChild as HTMLElement
228
+ expect(container).toHaveStyle({
229
+ borderTopLeftRadius: '8px',
230
+ borderTopRightRadius: '8px',
231
+ borderBottomLeftRadius: '12px',
232
+ borderBottomRightRadius: '12px',
233
+ })
234
+ })
235
+
236
+ it('applies middle position border radius', () => {
237
+ render(
238
+ <ExerciseCard
239
+ {...baseCollapsedProps}
240
+ supersetPosition="middle"
241
+ />,
242
+ )
243
+ const card = screen.getByTestId('exercise-card')
244
+ const container = card.firstChild as HTMLElement
245
+ expect(container).toHaveStyle({
246
+ borderTopLeftRadius: '8px',
247
+ borderTopRightRadius: '8px',
248
+ borderBottomLeftRadius: '8px',
249
+ borderBottomRightRadius: '8px',
250
+ })
251
+ })
252
+ })
253
+
254
+ describe('accessibility', () => {
255
+ it('has correct accessibility label for collapsed state', () => {
256
+ render(<ExerciseCard {...baseCollapsedProps} />)
257
+ expect(
258
+ screen.getByLabelText('Bench Press, 3\u00D76 @ 175 lbs'),
259
+ ).toBeInTheDocument()
260
+ })
261
+
262
+ it('has correct accessibility label for upcoming state with prescription', () => {
263
+ render(
264
+ <ExerciseCard
265
+ name="Deadlift"
266
+ state="upcoming"
267
+ onToggle={vi.fn()}
268
+ prescription="3x8 @ RPE 8"
269
+ />,
270
+ )
271
+ expect(
272
+ screen.getByLabelText('Deadlift, 3x8 @ RPE 8'),
273
+ ).toBeInTheDocument()
274
+ })
275
+
276
+ it('has no accessibility violations in collapsed state', async () => {
277
+ const { container } = render(
278
+ <ExerciseCard
279
+ {...baseCollapsedProps}
280
+ e1rm={{ value: 225, unit: 'lbs' }}
281
+ isPR
282
+ setVelocities={[[1.1, 0.95]]}
283
+ totalPlannedSets={3}
284
+ />,
285
+ )
286
+ const results = await axe(container)
287
+ expect(results).toHaveNoViolations()
288
+ })
289
+
290
+ it('has no accessibility violations in expanded state', async () => {
291
+ const { container } = render(
292
+ <ExerciseCard
293
+ name="Squat"
294
+ state="expanded"
295
+ onToggle={vi.fn()}
296
+ summary={{ sets: 3, reps: 8, weight: 185, unit: 'lbs' }}
297
+ sets={baseSets}
298
+ tempo={[2, 1, 3, 0]}
299
+ />,
300
+ )
301
+ const results = await axe(container)
302
+ expect(results).toHaveNoViolations()
303
+ })
304
+
305
+ it('has no accessibility violations in upcoming state', async () => {
306
+ const { container } = render(
307
+ <ExerciseCard
308
+ name="Deadlift"
309
+ state="upcoming"
310
+ onToggle={vi.fn()}
311
+ prescription="3x8 @ RPE 8"
312
+ previousBest="185 lbs x 10"
313
+ />,
314
+ )
315
+ const results = await axe(container)
316
+ expect(results).toHaveNoViolations()
317
+ })
318
+ })
319
+ })