@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,161 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useState } from 'react'
3
+ import { View, Pressable, Animated, type ViewProps } from 'react-native'
4
+
5
+ const BRAND_PRIMARY = '#FF7900'
6
+
7
+ export type MesoStatus = 'completed' | 'current' | 'upcoming'
8
+
9
+ export interface Meso {
10
+ id: string
11
+ name: string
12
+ weekCount: number
13
+ status: MesoStatus
14
+ /**
15
+ * 1-based week index of progress within a `current` meso.
16
+ * Drives the solid fill width. Ignored for non-current mesos.
17
+ */
18
+ currentWeek?: number
19
+ }
20
+
21
+ export interface MesoProgressBarProps extends ViewProps {
22
+ mesos: Meso[]
23
+ activeMesoId: string | null
24
+ onMesoPress: (mesoId: string) => void
25
+ className?: string
26
+ }
27
+
28
+ const segmentBgColors: Record<MesoStatus, string> = {
29
+ completed: 'rgba(20,184,166,0.3)',
30
+ current: 'rgba(255,121,0,0.5)',
31
+ upcoming: 'rgba(255,255,255,0.06)',
32
+ }
33
+
34
+ function clampProgress(currentWeek: number | undefined, weekCount: number): number {
35
+ if (currentWeek == null || weekCount <= 0) return 0
36
+ const ratio = currentWeek / weekCount
37
+ return Math.max(0, Math.min(1, ratio))
38
+ }
39
+
40
+ interface SegmentProps {
41
+ meso: Meso
42
+ isActive: boolean
43
+ onPress: (id: string) => void
44
+ }
45
+
46
+ function MesoSegment({ meso, isActive, onPress }: SegmentProps) {
47
+ const [pressScale] = useState(() => new Animated.Value(1))
48
+
49
+ const handlePressIn = () => {
50
+ Animated.timing(pressScale, {
51
+ toValue: 0.98,
52
+ duration: 150,
53
+ useNativeDriver: true,
54
+ }).start()
55
+ }
56
+
57
+ const handlePressOut = () => {
58
+ Animated.timing(pressScale, {
59
+ toValue: 1,
60
+ duration: 150,
61
+ useNativeDriver: true,
62
+ }).start()
63
+ }
64
+
65
+ const progress = meso.status === 'current'
66
+ ? clampProgress(meso.currentWeek, meso.weekCount)
67
+ : 0
68
+
69
+ return (
70
+ <Pressable
71
+ style={{ flex: meso.weekCount }}
72
+ onPress={() => onPress(meso.id)}
73
+ onPressIn={handlePressIn}
74
+ onPressOut={handlePressOut}
75
+ accessibilityRole="button"
76
+ accessibilityLabel={`${meso.name}, ${meso.status}`}
77
+ testID={`meso-segment-${meso.id}`}
78
+ >
79
+ <Animated.View
80
+ style={[
81
+ {
82
+ height: 8,
83
+ borderRadius: 4,
84
+ overflow: 'hidden',
85
+ backgroundColor: segmentBgColors[meso.status],
86
+ transform: [{ scale: pressScale }],
87
+ },
88
+ isActive
89
+ ? {
90
+ borderWidth: 2,
91
+ borderColor: BRAND_PRIMARY,
92
+ transform: [{ scale: 1.02 }, { scale: pressScale }],
93
+ }
94
+ : undefined,
95
+ ]}
96
+ testID={`meso-segment-inner-${meso.id}`}
97
+ >
98
+ {meso.status === 'current' && progress > 0 && (
99
+ <View
100
+ style={{
101
+ position: 'absolute',
102
+ left: 0,
103
+ top: 0,
104
+ bottom: 0,
105
+ width: `${progress * 100}%`,
106
+ backgroundColor: BRAND_PRIMARY,
107
+ }}
108
+ testID={`meso-segment-fill-${meso.id}`}
109
+ />
110
+ )}
111
+ </Animated.View>
112
+ </Pressable>
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Segmented horizontal bar of mesocycles. Each segment is tappable and its
118
+ * flex width is proportional to the meso's `weekCount`. The active segment is
119
+ * highlighted with a 2px brand border to stay in sync with MesoCard selection.
120
+ *
121
+ * @example
122
+ * <MesoProgressBar
123
+ * mesos={[
124
+ * { id: 'm1', name: 'Accumulation', weekCount: 4, status: 'completed' },
125
+ * { id: 'm2', name: 'Intensification', weekCount: 3, status: 'current', currentWeek: 2 },
126
+ * { id: 'm3', name: 'Peak', weekCount: 2, status: 'upcoming' },
127
+ * ]}
128
+ * activeMesoId="m2"
129
+ * onMesoPress={(id) => console.log(id)}
130
+ * />
131
+ */
132
+ export function MesoProgressBar({
133
+ mesos,
134
+ activeMesoId,
135
+ onMesoPress,
136
+ className,
137
+ ...props
138
+ }: MesoProgressBarProps) {
139
+ return (
140
+ <View
141
+ className={className}
142
+ style={{
143
+ flexDirection: 'row',
144
+ gap: 2,
145
+ paddingHorizontal: 16,
146
+ alignItems: 'center',
147
+ }}
148
+ testID="meso-progress-bar"
149
+ {...props}
150
+ >
151
+ {mesos.map((meso) => (
152
+ <MesoSegment
153
+ key={meso.id}
154
+ meso={meso}
155
+ isActive={meso.id === activeMesoId}
156
+ onPress={onMesoPress}
157
+ />
158
+ ))}
159
+ </View>
160
+ )
161
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { MesoStatusCard } from './MesoStatusCard'
4
+
5
+ const meta: Meta<typeof MesoStatusCard> = {
6
+ title: 'Custom/Workout/MesoStatusCard',
7
+ component: MesoStatusCard,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ mesoName: {
11
+ control: 'text',
12
+ description: 'Mesocycle name shown in the header',
13
+ },
14
+ mesoSubtitle: {
15
+ control: 'text',
16
+ description: 'Context line, e.g. "Week 6 of 8 · Upper/Lower"',
17
+ },
18
+ statusBadge: {
19
+ control: 'object',
20
+ description: 'Header status pill { label, variant }',
21
+ },
22
+ metrics: {
23
+ control: 'object',
24
+ description: 'Prescription-vs-actual metrics, rendered as a 2x2 grid',
25
+ },
26
+ gauges: {
27
+ control: 'object',
28
+ description: 'Intensity / volume gauges (level 0-1, optional sublabel)',
29
+ },
30
+ coaching: {
31
+ control: 'object',
32
+ description: 'Optional coaching box with optional bold highlights',
33
+ },
34
+ nextTarget: {
35
+ control: 'object',
36
+ description: 'Optional next-session target box { icon, text }',
37
+ },
38
+ },
39
+ decorators: [
40
+ (Story) => (
41
+ <View style={{ maxWidth: 380, padding: 16 }}>
42
+ <Story />
43
+ </View>
44
+ ),
45
+ ],
46
+ }
47
+
48
+ export default meta
49
+ type Story = StoryObj<typeof MesoStatusCard>
50
+
51
+ export const Default: Story = {
52
+ args: {
53
+ mesoName: 'Hypertrophy Block',
54
+ mesoSubtitle: 'Week 6 of 8 · Upper/Lower',
55
+ statusBadge: { label: 'On Track', variant: 'success' },
56
+ metrics: [
57
+ { label: 'Prescribed', value: '3×8 @ RPE 8' },
58
+ { label: 'Actual', value: '3×8 @ 185 lbs' },
59
+ { label: 'Volume', value: '+12% vs wk 1' },
60
+ { label: 'e1RM', value: '232 lbs' },
61
+ ],
62
+ gauges: [
63
+ { label: 'Intensity', level: 0.68, sublabel: 'Actual' },
64
+ { label: 'Volume', level: 0.82, sublabel: 'vs MRV' },
65
+ ],
66
+ coaching: {
67
+ text: 'Velocity is holding well — add load next session to keep intensity progressive.',
68
+ highlights: ['add load'],
69
+ },
70
+ nextTarget: { icon: '→', text: 'Next: 190 lbs × 8 @ RPE 8.5' },
71
+ },
72
+ }
73
+
74
+ export const Warning: Story = {
75
+ args: {
76
+ ...Default.args,
77
+ mesoName: 'Intensification',
78
+ mesoSubtitle: 'Week 7 of 8 · Push/Pull/Legs',
79
+ statusBadge: { label: 'Watch Fatigue', variant: 'warning' },
80
+ gauges: [
81
+ { label: 'Intensity', level: 0.78, sublabel: 'Actual' },
82
+ { label: 'Volume', level: 0.94, sublabel: 'vs MRV' },
83
+ ],
84
+ coaching: {
85
+ text: 'Velocity loss is creeping up. Hold load and reduce one set to manage fatigue.',
86
+ highlights: ['Hold load', 'reduce one set'],
87
+ },
88
+ },
89
+ }
90
+
91
+ export const Overreaching: Story = {
92
+ args: {
93
+ ...Default.args,
94
+ mesoName: 'Peaking Block',
95
+ mesoSubtitle: 'Week 8 of 8 · Upper/Lower',
96
+ statusBadge: { label: 'Deload Soon', variant: 'error' },
97
+ metrics: [
98
+ { label: 'Prescribed', value: '4×5 @ RPE 9' },
99
+ { label: 'Actual', value: '4×4 @ 205 lbs' },
100
+ { label: 'Volume', value: '−8% vs wk 6' },
101
+ { label: 'e1RM', value: '241 lbs' },
102
+ ],
103
+ gauges: [
104
+ { label: 'Intensity', level: 0.92, sublabel: 'Actual' },
105
+ { label: 'Volume', level: 0.45, sublabel: 'vs MRV' },
106
+ ],
107
+ coaching: {
108
+ text: 'Bar speed has dropped sharply across sessions. Schedule a deload next week.',
109
+ highlights: ['deload'],
110
+ },
111
+ nextTarget: { icon: '↓', text: 'Next: deload — 60% × 3×5' },
112
+ },
113
+ }
114
+
115
+ export const Minimal: Story = {
116
+ args: {
117
+ mesoName: 'Base Phase',
118
+ mesoSubtitle: 'Week 2 of 6 · Full Body',
119
+ statusBadge: { label: 'On Track', variant: 'success' },
120
+ metrics: [
121
+ { label: 'Prescribed', value: '3×10 @ RPE 7' },
122
+ { label: 'Actual', value: '3×10 @ 135 lbs' },
123
+ ],
124
+ gauges: [{ label: 'Intensity', level: 0.5 }],
125
+ },
126
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { MesoStatusCard, type MesoStatusCardProps } from './MesoStatusCard'
5
+
6
+ const baseProps: MesoStatusCardProps = {
7
+ mesoName: 'Hypertrophy Block',
8
+ mesoSubtitle: 'Week 6 of 8 · Upper/Lower',
9
+ statusBadge: { label: 'On Track', variant: 'success' },
10
+ metrics: [
11
+ { label: 'Prescribed', value: '3×8 @ RPE 8' },
12
+ { label: 'Actual', value: '3×8 @ 185 lbs' },
13
+ { label: 'Volume', value: '+12% vs wk 1' },
14
+ { label: 'e1RM', value: '232 lbs' },
15
+ ],
16
+ gauges: [
17
+ { label: 'Intensity', level: 0.68, sublabel: 'Actual' },
18
+ { label: 'Volume', level: 0.82, sublabel: 'vs MRV' },
19
+ ],
20
+ coaching: {
21
+ text: 'Velocity is holding — add load next session.',
22
+ highlights: ['add load'],
23
+ },
24
+ nextTarget: { icon: '→', text: 'Next: 190 lbs × 8' },
25
+ }
26
+
27
+ describe('MesoStatusCard', () => {
28
+ describe('rendering', () => {
29
+ it('renders the card, name, and subtitle', () => {
30
+ render(<MesoStatusCard {...baseProps} />)
31
+ expect(screen.getByTestId('meso-status-card')).toBeInTheDocument()
32
+ expect(screen.getByTestId('meso-status-card-name')).toHaveTextContent(
33
+ 'Hypertrophy Block',
34
+ )
35
+ expect(screen.getByTestId('meso-status-card-subtitle')).toHaveTextContent(
36
+ 'Week 6 of 8 · Upper/Lower',
37
+ )
38
+ })
39
+
40
+ it('renders the 3px gradient top accent', () => {
41
+ render(<MesoStatusCard {...baseProps} />)
42
+ expect(screen.getByTestId('meso-status-card-accent')).toBeInTheDocument()
43
+ })
44
+
45
+ it('renders the status badge with its label', () => {
46
+ render(<MesoStatusCard {...baseProps} />)
47
+ expect(screen.getByTestId('meso-status-card-badge')).toHaveTextContent(
48
+ 'On Track',
49
+ )
50
+ })
51
+ })
52
+
53
+ describe('metrics grid', () => {
54
+ it('renders one cell per metric with label and value', () => {
55
+ render(<MesoStatusCard {...baseProps} />)
56
+ const cells = screen.getAllByTestId('meso-status-card-metric')
57
+ expect(cells).toHaveLength(4)
58
+ expect(screen.getByText('Prescribed')).toBeInTheDocument()
59
+ expect(screen.getByText('3×8 @ 185 lbs')).toBeInTheDocument()
60
+ })
61
+
62
+ it('renders every metric cell even when labels repeat (stable unique keys)', () => {
63
+ render(
64
+ <MesoStatusCard
65
+ {...baseProps}
66
+ metrics={[
67
+ { label: 'Set', value: '1' },
68
+ { label: 'Set', value: '2' },
69
+ ]}
70
+ />,
71
+ )
72
+ expect(screen.getAllByTestId('meso-status-card-metric')).toHaveLength(2)
73
+ })
74
+
75
+ it('does not render the metrics grid when empty', () => {
76
+ render(<MesoStatusCard {...baseProps} metrics={[]} />)
77
+ expect(
78
+ screen.queryByTestId('meso-status-card-metrics'),
79
+ ).not.toBeInTheDocument()
80
+ })
81
+ })
82
+
83
+ describe('gauges', () => {
84
+ it('renders one gauge per entry with a marker', () => {
85
+ render(<MesoStatusCard {...baseProps} />)
86
+ expect(screen.getAllByTestId('meso-status-card-gauge')).toHaveLength(2)
87
+ expect(
88
+ screen.getAllByTestId('meso-status-card-gauge-marker'),
89
+ ).toHaveLength(2)
90
+ })
91
+
92
+ it('renders a 50% center line per gauge', () => {
93
+ render(<MesoStatusCard {...baseProps} />)
94
+ const center = screen.getAllByTestId('meso-status-card-gauge-center')[0]
95
+ expect(center).toHaveStyle({ left: '50%' })
96
+ })
97
+
98
+ it('exposes gauge level via progressbar value', () => {
99
+ render(
100
+ <MesoStatusCard
101
+ {...baseProps}
102
+ gauges={[{ label: 'Intensity', level: 0.7 }]}
103
+ />,
104
+ )
105
+ const gauge = screen.getByLabelText('Intensity: 70%')
106
+ expect(gauge).toHaveAttribute('role', 'progressbar')
107
+ expect(gauge).toHaveAttribute('aria-valuenow', '70')
108
+ })
109
+
110
+ it('clamps out-of-range levels', () => {
111
+ render(
112
+ <MesoStatusCard
113
+ {...baseProps}
114
+ gauges={[{ label: 'Volume', level: 1.7 }]}
115
+ />,
116
+ )
117
+ expect(screen.getByLabelText('Volume: 100%')).toBeInTheDocument()
118
+ })
119
+
120
+ it('guards a non-finite level, rendering 0% instead of NaN%', () => {
121
+ render(
122
+ <MesoStatusCard
123
+ {...baseProps}
124
+ gauges={[{ label: 'Intensity', level: Number.NaN }]}
125
+ />,
126
+ )
127
+ expect(screen.getByLabelText('Intensity: 0%')).toBeInTheDocument()
128
+ expect(screen.queryByLabelText('Intensity: NaN%')).not.toBeInTheDocument()
129
+ })
130
+
131
+ it('renders every gauge even when labels repeat (stable unique keys)', () => {
132
+ render(
133
+ <MesoStatusCard
134
+ {...baseProps}
135
+ gauges={[
136
+ { label: 'Load', level: 0.3 },
137
+ { label: 'Load', level: 0.6 },
138
+ ]}
139
+ />,
140
+ )
141
+ expect(screen.getAllByTestId('meso-status-card-gauge')).toHaveLength(2)
142
+ })
143
+ })
144
+
145
+ describe('coaching', () => {
146
+ it('renders coaching copy with bolded highlights', () => {
147
+ render(<MesoStatusCard {...baseProps} />)
148
+ const box = screen.getByTestId('meso-status-card-coaching')
149
+ expect(box).toHaveTextContent('Velocity is holding — add load next session.')
150
+ expect(screen.getByText('add load')).toBeInTheDocument()
151
+ })
152
+
153
+ it('omits the coaching box when not provided', () => {
154
+ render(<MesoStatusCard {...baseProps} coaching={undefined} />)
155
+ expect(
156
+ screen.queryByTestId('meso-status-card-coaching'),
157
+ ).not.toBeInTheDocument()
158
+ })
159
+ })
160
+
161
+ describe('next target', () => {
162
+ it('renders the next-target box with icon and text', () => {
163
+ render(<MesoStatusCard {...baseProps} />)
164
+ const box = screen.getByTestId('meso-status-card-next-target')
165
+ expect(box).toHaveTextContent('Next: 190 lbs × 8')
166
+ })
167
+
168
+ it('omits the next-target box when not provided', () => {
169
+ render(<MesoStatusCard {...baseProps} nextTarget={undefined} />)
170
+ expect(
171
+ screen.queryByTestId('meso-status-card-next-target'),
172
+ ).not.toBeInTheDocument()
173
+ })
174
+ })
175
+
176
+ describe('accessibility', () => {
177
+ it('exposes the card as an article with a descriptive label', () => {
178
+ render(<MesoStatusCard {...baseProps} />)
179
+ const article = screen.getByRole('article')
180
+ expect(article).toHaveAttribute(
181
+ 'aria-label',
182
+ 'Mesocycle status for Hypertrophy Block',
183
+ )
184
+ })
185
+
186
+ it('has no accessibility violations', async () => {
187
+ const { container } = render(<MesoStatusCard {...baseProps} />)
188
+ expect(await axe(container)).toHaveNoViolations()
189
+ })
190
+
191
+ it('has no accessibility violations with all features and every badge variant', async () => {
192
+ const { container } = render(
193
+ <>
194
+ <MesoStatusCard
195
+ {...baseProps}
196
+ statusBadge={{ label: 'Watch Fatigue', variant: 'warning' }}
197
+ />
198
+ <MesoStatusCard
199
+ {...baseProps}
200
+ statusBadge={{ label: 'Deload Soon', variant: 'error' }}
201
+ coaching={undefined}
202
+ nextTarget={undefined}
203
+ />
204
+ </>,
205
+ )
206
+ expect(await axe(container)).toHaveNoViolations()
207
+ })
208
+ })
209
+ })