@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,181 @@
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 { ReadinessCheck, type ReadinessFactor } from './ReadinessCheck'
5
+
6
+ function makeFactors(overrides: Partial<Record<string, number>> = {}): ReadinessFactor[] {
7
+ return [
8
+ { id: 'sleep', label: 'Sleep', value: overrides.sleep ?? 4, onChange: vi.fn() },
9
+ { id: 'soreness', label: 'Soreness', value: overrides.soreness ?? 3, onChange: vi.fn() },
10
+ { id: 'motivation', label: 'Motivation', value: overrides.motivation ?? 5, onChange: vi.fn() },
11
+ { id: 'stress', label: 'Stress', value: overrides.stress ?? 3, onChange: vi.fn() },
12
+ ]
13
+ }
14
+
15
+ const baseProps = {
16
+ score: 78,
17
+ warmUpCompleted: false,
18
+ onConfirm: vi.fn(),
19
+ }
20
+
21
+ describe('ReadinessCheck', () => {
22
+ describe('rendering', () => {
23
+ it('renders the title and one row per factor', () => {
24
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
25
+ expect(screen.getByTestId('readiness-check-title')).toHaveTextContent(
26
+ 'How are you feeling?',
27
+ )
28
+ expect(screen.getByTestId('readiness-check-factor-sleep')).toBeInTheDocument()
29
+ expect(screen.getByTestId('readiness-check-factor-stress')).toBeInTheDocument()
30
+ })
31
+
32
+ it('renders five emoji options per factor', () => {
33
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
34
+ // 4 factors x 5 options
35
+ expect(screen.getAllByTestId('readiness-check-emoji')).toHaveLength(20)
36
+ })
37
+
38
+ it('shows the score in the gauge', () => {
39
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} score={64} />)
40
+ expect(screen.getByTestId('readiness-check-score-value')).toHaveTextContent('64')
41
+ })
42
+ })
43
+
44
+ describe('interaction', () => {
45
+ it('calls the factor onChange with the tapped 1-5 level', () => {
46
+ const factors = makeFactors()
47
+ render(<ReadinessCheck {...baseProps} factors={factors} />)
48
+ const sleepOptions = screen
49
+ .getByTestId('readiness-check-factor-sleep')
50
+ .querySelectorAll('[data-testid="readiness-check-emoji"]')
51
+ fireEvent.click(sleepOptions[0])
52
+ expect(factors[0].onChange).toHaveBeenCalledWith(1)
53
+ fireEvent.click(sleepOptions[4])
54
+ expect(factors[0].onChange).toHaveBeenCalledWith(5)
55
+ })
56
+
57
+ it('marks the selected level as checked', () => {
58
+ render(<ReadinessCheck {...baseProps} factors={makeFactors({ sleep: 2 })} />)
59
+ const sleepOptions = screen
60
+ .getByTestId('readiness-check-factor-sleep')
61
+ .querySelectorAll('[data-testid="readiness-check-emoji"]')
62
+ expect(sleepOptions[1]).toHaveAttribute('aria-checked', 'true')
63
+ expect(sleepOptions[0]).toHaveAttribute('aria-checked', 'false')
64
+ })
65
+
66
+ it('fires onConfirm when the start button is pressed', () => {
67
+ const onConfirm = vi.fn()
68
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} onConfirm={onConfirm} />)
69
+ fireEvent.click(screen.getByTestId('readiness-check-confirm'))
70
+ expect(onConfirm).toHaveBeenCalledOnce()
71
+ })
72
+ })
73
+
74
+ describe('warm-up validation', () => {
75
+ it('does not render the warm-up card until completed', () => {
76
+ render(
77
+ <ReadinessCheck
78
+ {...baseProps}
79
+ factors={makeFactors()}
80
+ warmUpValidation={{ velocityDeficit: 5, recommendation: 'OK', status: 'good' }}
81
+ />,
82
+ )
83
+ expect(screen.queryByTestId('readiness-check-warmup')).not.toBeInTheDocument()
84
+ })
85
+
86
+ it('renders deficit, recommendation, and status badge once completed', () => {
87
+ render(
88
+ <ReadinessCheck
89
+ {...baseProps}
90
+ factors={makeFactors()}
91
+ warmUpCompleted
92
+ warmUpValidation={{
93
+ velocityDeficit: 18,
94
+ recommendation: 'Reduce load today.',
95
+ status: 'caution',
96
+ }}
97
+ />,
98
+ )
99
+ expect(screen.getByTestId('readiness-check-warmup-deficit')).toHaveTextContent(
100
+ '18% below 14-day average',
101
+ )
102
+ expect(screen.getByTestId('readiness-check-warmup-recommendation')).toHaveTextContent(
103
+ 'Reduce load today.',
104
+ )
105
+ expect(screen.getByTestId('readiness-check-warmup-badge')).toHaveTextContent('Caution')
106
+ })
107
+
108
+ it('phrases a zero deficit as on-average rather than "below"', () => {
109
+ render(
110
+ <ReadinessCheck
111
+ {...baseProps}
112
+ factors={makeFactors()}
113
+ warmUpCompleted
114
+ warmUpValidation={{ velocityDeficit: 0, recommendation: 'Proceed.', status: 'good' }}
115
+ />,
116
+ )
117
+ expect(screen.getByTestId('readiness-check-warmup-deficit')).toHaveTextContent(
118
+ 'Velocity on 14-day average',
119
+ )
120
+ })
121
+
122
+ it('phrases a negative deficit as above average', () => {
123
+ render(
124
+ <ReadinessCheck
125
+ {...baseProps}
126
+ factors={makeFactors()}
127
+ warmUpCompleted
128
+ warmUpValidation={{ velocityDeficit: -6, recommendation: 'Proceed.', status: 'good' }}
129
+ />,
130
+ )
131
+ expect(screen.getByTestId('readiness-check-warmup-deficit')).toHaveTextContent(
132
+ 'Velocity 6% above 14-day average',
133
+ )
134
+ })
135
+ })
136
+
137
+ describe('accessibility', () => {
138
+ it('labels each emoji as a radio with level and factor', () => {
139
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
140
+ const radio = screen.getByLabelText('Sleep level 3 of 5')
141
+ expect(radio).toHaveAttribute('role', 'radio')
142
+ })
143
+
144
+ it('groups each factor as a radiogroup', () => {
145
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} />)
146
+ const groups = screen.getAllByRole('radiogroup')
147
+ expect(groups).toHaveLength(4)
148
+ })
149
+
150
+ it('exposes the readiness score on the gauge', () => {
151
+ render(<ReadinessCheck {...baseProps} factors={makeFactors()} score={78} />)
152
+ expect(
153
+ screen.getByLabelText('Readiness score: 78 out of 100'),
154
+ ).toBeInTheDocument()
155
+ })
156
+
157
+ it('has no accessibility violations', async () => {
158
+ const { container } = render(
159
+ <ReadinessCheck {...baseProps} factors={makeFactors()} />,
160
+ )
161
+ expect(await axe(container)).toHaveNoViolations()
162
+ })
163
+
164
+ it('has no accessibility violations with warm-up and all features', async () => {
165
+ const { container } = render(
166
+ <ReadinessCheck
167
+ factors={makeFactors()}
168
+ score={88}
169
+ warmUpCompleted
170
+ warmUpValidation={{
171
+ velocityDeficit: 6,
172
+ recommendation: 'Warm up thoroughly.',
173
+ status: 'moderate',
174
+ }}
175
+ onConfirm={vi.fn()}
176
+ />,
177
+ )
178
+ expect(await axe(container)).toHaveNoViolations()
179
+ })
180
+ })
181
+ })
@@ -0,0 +1,266 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, Pressable } from 'react-native'
3
+ import { Card } from '../../ui/card'
4
+ import { Badge, type BadgeColor } from '../../ui/badge'
5
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
6
+
7
+ const BRAND_PRIMARY = '#FF7900'
8
+ const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
9
+ const STATUS_SUCCESS = '#14B8A6'
10
+ const STATUS_WARNING = '#FFB020'
11
+ const STATUS_ERROR = '#D14343'
12
+ const TEXT_PRIMARY = '#F3F4F6'
13
+ const TEXT_SECONDARY = '#9CA3AF'
14
+
15
+ /** Emoji option sets per known factor, keyed by lowercase id/label. */
16
+ const EMOJI_SETS: Record<string, readonly string[]> = {
17
+ sleep: ['😴', '😐', '🙂', '😊', '🤩'],
18
+ soreness: ['😵', '😣', '😐', '🙂', '💪'],
19
+ motivation: ['😑', '😐', '🙂', '😄', '🔥'],
20
+ stress: ['😰', '😟', '😐', '😌', '😎'],
21
+ }
22
+ const DEFAULT_EMOJI_SET = ['😞', '😕', '😐', '🙂', '😄'] as const
23
+
24
+ export type WarmUpStatus = 'good' | 'moderate' | 'caution'
25
+
26
+ export interface ReadinessFactor {
27
+ /** Stable identifier; also used to pick the emoji set (sleep/soreness/motivation/stress). */
28
+ id: string
29
+ /** Human label, e.g. "Sleep". */
30
+ label: string
31
+ /** Selected level, 1-5. */
32
+ value: number
33
+ /** Called with the new 1-5 level when an emoji is tapped. */
34
+ onChange: (value: number) => void
35
+ }
36
+
37
+ export interface WarmUpValidation {
38
+ /** Percent below the 14-day rolling velocity average. */
39
+ velocityDeficit: number
40
+ /** Short human recommendation, e.g. "Velocity on target — proceed as planned". */
41
+ recommendation: string
42
+ /** Severity of the deficit. */
43
+ status: WarmUpStatus
44
+ }
45
+
46
+ export interface ReadinessCheckProps {
47
+ /** Subjective readiness factors (Sleep, Soreness, Motivation, Stress). */
48
+ factors: ReadinessFactor[]
49
+ /** Computed overall readiness score, 0-100. */
50
+ score: number
51
+ /** VBT warm-up validation result, shown once the warm-up set is performed. */
52
+ warmUpValidation?: WarmUpValidation
53
+ /** Whether the VBT warm-up has been performed. */
54
+ warmUpCompleted: boolean
55
+ /** Called when the assessment is confirmed and the workout can begin. */
56
+ onConfirm: () => void
57
+ className?: string
58
+ }
59
+
60
+ /** Maps a 0-100 readiness score onto a status color. */
61
+ function scoreColor(score: number): string {
62
+ if (score < 40) return STATUS_ERROR
63
+ if (score < 70) return STATUS_WARNING
64
+ return STATUS_SUCCESS
65
+ }
66
+
67
+ const WARMUP_BADGE: Record<WarmUpStatus, { color: BadgeColor; label: string }> = {
68
+ good: { color: 'success', label: 'Good' },
69
+ moderate: { color: 'warning', label: 'Moderate' },
70
+ caution: { color: 'error', label: 'Caution' },
71
+ }
72
+
73
+ function emojiSet(factor: ReadinessFactor): readonly string[] {
74
+ return EMOJI_SETS[factor.id.toLowerCase()] ?? EMOJI_SETS[factor.label.toLowerCase()] ?? DEFAULT_EMOJI_SET
75
+ }
76
+
77
+ /** Warm-up velocity phrasing. Deficit is % below the 14-day average, so a
78
+ * non-positive deficit means velocity is on/above average — never "below". */
79
+ function velocityDeficitText(deficit: number): string {
80
+ if (deficit > 0) return `Velocity ${deficit}% below 14-day average`
81
+ if (deficit < 0) return `Velocity ${Math.abs(deficit)}% above 14-day average`
82
+ return 'Velocity on 14-day average'
83
+ }
84
+
85
+ interface EmojiSliderProps {
86
+ factor: ReadinessFactor
87
+ }
88
+
89
+ /** One factor row: label plus a radiogroup of five tappable emoji. */
90
+ function EmojiSlider({ factor }: EmojiSliderProps) {
91
+ const emojis = emojiSet(factor)
92
+ return (
93
+ <View style={{ marginTop: 14 }} testID={`readiness-check-factor-${factor.id}`}>
94
+ <Text
95
+ style={{ fontSize: 12, fontWeight: '500', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
96
+ testID="readiness-check-factor-label"
97
+ >
98
+ {factor.label}
99
+ </Text>
100
+ <View
101
+ className="flex-row items-center"
102
+ style={{ marginTop: 6, gap: 6 }}
103
+ accessibilityRole="radiogroup"
104
+ aria-label={factor.label}
105
+ testID="readiness-check-factor-options"
106
+ >
107
+ {emojis.map((emoji, index) => {
108
+ const level = index + 1
109
+ const selected = factor.value === level
110
+ return (
111
+ <Pressable
112
+ key={level}
113
+ onPress={() => factor.onChange(level)}
114
+ accessibilityRole="radio"
115
+ accessibilityLabel={`${factor.label} level ${level} of 5`}
116
+ aria-checked={selected}
117
+ testID="readiness-check-emoji"
118
+ style={({ pressed }) => ({
119
+ flex: 1,
120
+ alignItems: 'center',
121
+ paddingVertical: 8,
122
+ borderRadius: 8,
123
+ borderWidth: 1,
124
+ borderColor: selected ? BRAND_PRIMARY : WORKOUT_TOKENS.border.default,
125
+ backgroundColor: selected ? BRAND_PRIMARY_SUBTLE : WORKOUT_TOKENS.surface.raised,
126
+ opacity: selected ? 1 : pressed ? 0.8 : 0.55,
127
+ transform: [{ scale: selected ? 1.06 : 1 }],
128
+ })}
129
+ >
130
+ <Text style={{ fontSize: 20 }}>{emoji}</Text>
131
+ </Pressable>
132
+ )
133
+ })}
134
+ </View>
135
+ </View>
136
+ )
137
+ }
138
+
139
+ /** Circular score gauge: 60px ring colored by range with the score centered. */
140
+ function ScoreGauge({ score }: { score: number }) {
141
+ const color = scoreColor(score)
142
+ return (
143
+ <View
144
+ accessibilityRole="image"
145
+ accessibilityLabel={`Readiness score: ${score} out of 100`}
146
+ testID="readiness-check-score"
147
+ style={{
148
+ width: 60,
149
+ height: 60,
150
+ borderRadius: 30,
151
+ borderWidth: 4,
152
+ borderColor: color,
153
+ backgroundColor: WORKOUT_TOKENS.surface.raised,
154
+ alignItems: 'center',
155
+ justifyContent: 'center',
156
+ }}
157
+ >
158
+ <Text
159
+ style={{ fontSize: 20, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color }}
160
+ testID="readiness-check-score-value"
161
+ >
162
+ {score}
163
+ </Text>
164
+ </View>
165
+ )
166
+ }
167
+
168
+ /** VBT warm-up validation card with status badge and recommendation. */
169
+ function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
170
+ const badge = WARMUP_BADGE[validation.status]
171
+ return (
172
+ <View
173
+ style={{
174
+ marginTop: 16,
175
+ padding: 12,
176
+ borderRadius: 8,
177
+ borderWidth: 1,
178
+ borderColor: WORKOUT_TOKENS.border.default,
179
+ backgroundColor: WORKOUT_TOKENS.surface.raised,
180
+ }}
181
+ testID="readiness-check-warmup"
182
+ >
183
+ <View className="flex-row items-center justify-between" style={{ gap: 8 }}>
184
+ <Text
185
+ style={{ fontSize: 12, fontWeight: '600', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
186
+ testID="readiness-check-warmup-deficit"
187
+ >
188
+ {velocityDeficitText(validation.velocityDeficit)}
189
+ </Text>
190
+ <Badge variant="subtle" color={badge.color} size="sm" testID="readiness-check-warmup-badge">
191
+ {badge.label}
192
+ </Badge>
193
+ </View>
194
+ <Text
195
+ style={{ marginTop: 6, fontSize: 13, fontFamily: 'Inter, sans-serif', color: TEXT_PRIMARY }}
196
+ testID="readiness-check-warmup-recommendation"
197
+ >
198
+ {validation.recommendation}
199
+ </Text>
200
+ </View>
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Pre-workout readiness assessment combining subjective emoji sliders with an
206
+ * objective VBT warm-up validation and a computed readiness score.
207
+ *
208
+ * @example
209
+ * <ReadinessCheck
210
+ * factors={[{ id: 'sleep', label: 'Sleep', value: 4, onChange: setSleep }]}
211
+ * score={78}
212
+ * warmUpCompleted
213
+ * warmUpValidation={{ velocityDeficit: 4, recommendation: 'On target', status: 'good' }}
214
+ * onConfirm={start}
215
+ * />
216
+ */
217
+ export function ReadinessCheck({
218
+ factors,
219
+ score,
220
+ warmUpValidation,
221
+ warmUpCompleted,
222
+ onConfirm,
223
+ className,
224
+ }: ReadinessCheckProps) {
225
+ return (
226
+ <Card variant="outline" elevation={2} className={className} testID="readiness-check">
227
+ <View style={{ padding: 16 }}>
228
+ <View className="flex-row items-center justify-between" style={{ gap: 12 }}>
229
+ <Text
230
+ style={{ fontSize: 18, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color: TEXT_PRIMARY }}
231
+ testID="readiness-check-title"
232
+ >
233
+ How are you feeling?
234
+ </Text>
235
+ <ScoreGauge score={score} />
236
+ </View>
237
+
238
+ {factors.map((factor) => (
239
+ <EmojiSlider key={factor.id} factor={factor} />
240
+ ))}
241
+
242
+ {warmUpCompleted && warmUpValidation && <WarmUpCard validation={warmUpValidation} />}
243
+
244
+ <Pressable
245
+ onPress={onConfirm}
246
+ accessibilityRole="button"
247
+ accessibilityLabel="Confirm readiness and start workout"
248
+ testID="readiness-check-confirm"
249
+ style={({ pressed }) => ({
250
+ marginTop: 16,
251
+ width: '100%',
252
+ alignItems: 'center',
253
+ backgroundColor: BRAND_PRIMARY,
254
+ borderRadius: 8,
255
+ paddingVertical: 12,
256
+ opacity: pressed ? 0.8 : 1,
257
+ })}
258
+ >
259
+ <Text style={{ fontSize: 13, fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#FFFFFF' }}>
260
+ Start Workout
261
+ </Text>
262
+ </Pressable>
263
+ </View>
264
+ </Card>
265
+ )
266
+ }
@@ -1,6 +1,8 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import React from 'react'
3
2
  import { View, Text, Pressable } from 'react-native'
3
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
4
+
5
+ const BRAND_PRIMARY = '#FF7900'
4
6
 
5
7
  export interface RestTimerProps {
6
8
  totalSeconds: number
@@ -32,9 +34,9 @@ export function RestTimer({
32
34
  <View
33
35
  style={{
34
36
  width: '100%',
35
- backgroundColor: '#1C1C1C',
37
+ backgroundColor: WORKOUT_TOKENS.surface.raised,
36
38
  borderTopWidth: 1,
37
- borderTopColor: '#1F1F1F',
39
+ borderTopColor: WORKOUT_TOKENS.border.default,
38
40
  paddingVertical: 12,
39
41
  paddingHorizontal: 16,
40
42
  }}
@@ -101,7 +103,7 @@ export function RestTimer({
101
103
  <View
102
104
  style={{
103
105
  height: 3,
104
- backgroundColor: '#1F1F1F',
106
+ backgroundColor: WORKOUT_TOKENS.border.default,
105
107
  borderRadius: 2,
106
108
  marginBottom: 12,
107
109
  }}
@@ -110,7 +112,7 @@ export function RestTimer({
110
112
  <View
111
113
  style={{
112
114
  height: '100%',
113
- backgroundColor: '#FF7900',
115
+ backgroundColor: BRAND_PRIMARY,
114
116
  borderRadius: 2,
115
117
  width: `${progressPct}%`,
116
118
  }}
@@ -160,7 +162,7 @@ export function RestTimer({
160
162
  fontSize: 11,
161
163
  fontFamily: 'Inter, sans-serif',
162
164
  fontWeight: '600',
163
- color: '#FF7900',
165
+ color: BRAND_PRIMARY,
164
166
  }}
165
167
  >
166
168
  Skip
@@ -0,0 +1,165 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { SetRow } from './SetRow'
4
+
5
+ const meta: Meta<typeof SetRow> = {
6
+ title: 'Custom/Workout/SetRow',
7
+ component: SetRow,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ mode: {
11
+ control: 'select',
12
+ options: ['active', 'completed', 'history'],
13
+ description: 'Row display mode',
14
+ },
15
+ unit: {
16
+ control: 'select',
17
+ options: ['lbs', 'kg'],
18
+ description: 'Weight unit',
19
+ },
20
+ isNextSet: {
21
+ control: 'boolean',
22
+ description: 'Whether this is the next set to perform',
23
+ },
24
+ },
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof SetRow>
29
+
30
+ export const CompletedWithVelocity: Story = {
31
+ args: {
32
+ mode: 'completed',
33
+ setNumber: 1,
34
+ reps: 8,
35
+ weight: 135,
36
+ unit: 'lbs',
37
+ rpe: 7.5,
38
+ previous: { reps: 8, weight: 130 },
39
+ velocities: [1.1, 0.95, 0.82, 0.68, 0.55, 0.48, 0.42, 0.38],
40
+ },
41
+ }
42
+
43
+ export const ActiveWithTargets: Story = {
44
+ args: {
45
+ mode: 'active',
46
+ setNumber: 3,
47
+ reps: null,
48
+ weight: null,
49
+ unit: 'lbs',
50
+ previous: { reps: 8, weight: 135 },
51
+ targets: { reps: 10, weight: 150 },
52
+ },
53
+ }
54
+
55
+ export const ActiveNextSet: Story = {
56
+ args: {
57
+ mode: 'active',
58
+ setNumber: 2,
59
+ reps: null,
60
+ weight: null,
61
+ unit: 'lbs',
62
+ isNextSet: true,
63
+ targets: { reps: 8, weight: 135 },
64
+ previous: { reps: 8, weight: 130 },
65
+ },
66
+ }
67
+
68
+ export const HistorySet: Story = {
69
+ args: {
70
+ mode: 'history',
71
+ setNumber: 1,
72
+ reps: 10,
73
+ weight: 185,
74
+ unit: 'lbs',
75
+ rpe: 8,
76
+ previous: { reps: 10, weight: 180 },
77
+ },
78
+ }
79
+
80
+ export const WithTypeBadge: Story = {
81
+ args: {
82
+ mode: 'completed',
83
+ setNumber: 1,
84
+ reps: 5,
85
+ weight: 95,
86
+ unit: 'lbs',
87
+ setType: 'W',
88
+ previous: null,
89
+ },
90
+ }
91
+
92
+ export const WithPrBadges: Story = {
93
+ args: {
94
+ mode: 'completed',
95
+ setNumber: 3,
96
+ reps: 8,
97
+ weight: 225,
98
+ unit: 'lbs',
99
+ rpe: 9.5,
100
+ previous: { reps: 8, weight: 215 },
101
+ prBadges: [
102
+ { type: 'e1rm', label: 'PR e1RM' },
103
+ { type: 'weight', label: 'PR Weight' },
104
+ ],
105
+ },
106
+ }
107
+
108
+ export const WithRPE: Story = {
109
+ args: {
110
+ mode: 'completed',
111
+ setNumber: 4,
112
+ reps: 5,
113
+ weight: 275,
114
+ unit: 'lbs',
115
+ rpe: 10,
116
+ previous: { reps: 5, weight: 265 },
117
+ },
118
+ }
119
+
120
+ export const AllVariants: Story = {
121
+ render: () => (
122
+ <View style={{ gap: 4, padding: 16, maxWidth: 400 }}>
123
+ <SetRow
124
+ mode="completed"
125
+ setNumber={1}
126
+ reps={8}
127
+ weight={135}
128
+ unit="lbs"
129
+ rpe={7}
130
+ previous={{ reps: 8, weight: 130 }}
131
+ velocities={[1.1, 0.95, 0.82, 0.68, 0.55, 0.48, 0.42, 0.38]}
132
+ prBadges={[{ type: 'e1rm', label: 'PR e1RM' }]}
133
+ />
134
+ <SetRow
135
+ mode="completed"
136
+ setNumber={2}
137
+ reps={8}
138
+ weight={135}
139
+ unit="lbs"
140
+ rpe={8.5}
141
+ previous={{ reps: 8, weight: 135 }}
142
+ velocities={[0.95, 0.88, 0.78, 0.65, 0.52, 0.45, 0.40, 0.35]}
143
+ />
144
+ <SetRow
145
+ mode="active"
146
+ setNumber={3}
147
+ reps={null}
148
+ weight={null}
149
+ unit="lbs"
150
+ isNextSet
151
+ targets={{ reps: 8, weight: 135 }}
152
+ previous={{ reps: 8, weight: 135 }}
153
+ />
154
+ <SetRow
155
+ mode="active"
156
+ setNumber={4}
157
+ reps={null}
158
+ weight={null}
159
+ unit="lbs"
160
+ targets={{ reps: 8, weight: 135 }}
161
+ previous={{ reps: 8, weight: 135 }}
162
+ />
163
+ </View>
164
+ ),
165
+ }