@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,163 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import React from 'react'
3
+ import { View, Text, Pressable, TextInput } from 'react-native'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
+
6
+ export interface InputBarProps {
7
+ exerciseName: string
8
+ setNumber: number
9
+ totalSets: number | null
10
+ reps: string
11
+ weight: string
12
+ unit: 'lbs' | 'kg'
13
+ onRepsChange: (value: string) => void
14
+ onWeightChange: (value: string) => void
15
+ onRecord: () => void
16
+ canRecord: boolean
17
+ visible: boolean
18
+ }
19
+
20
+ const BRAND_PRIMARY = '#FF7900'
21
+ const TEXT_PRIMARY = '#F3F4F6'
22
+ const TEXT_TERTIARY = '#6B7280'
23
+
24
+ const inputStyle = {
25
+ fontSize: 14,
26
+ fontWeight: '600' as const,
27
+ fontFamily: 'Inter, sans-serif',
28
+ backgroundColor: WORKOUT_TOKENS.surface.raised,
29
+ borderWidth: 1,
30
+ borderColor: WORKOUT_TOKENS.border.strong,
31
+ borderRadius: 6,
32
+ textAlign: 'center' as const,
33
+ color: TEXT_PRIMARY,
34
+ paddingVertical: 5,
35
+ paddingHorizontal: 2,
36
+ }
37
+
38
+ export function InputBar({
39
+ exerciseName,
40
+ setNumber,
41
+ totalSets,
42
+ reps,
43
+ weight,
44
+ unit,
45
+ onRepsChange,
46
+ onWeightChange,
47
+ onRecord,
48
+ canRecord,
49
+ visible,
50
+ }: InputBarProps) {
51
+ if (!visible) return null
52
+
53
+ const setLabel = totalSets != null ? `Set ${setNumber}/${totalSets}` : `Set ${setNumber}`
54
+
55
+ return (
56
+ <View
57
+ style={{
58
+ width: '100%',
59
+ backgroundColor: WORKOUT_TOKENS.surface.elevated,
60
+ borderTopWidth: 1,
61
+ borderTopColor: WORKOUT_TOKENS.border.default,
62
+ paddingTop: 10,
63
+ paddingHorizontal: 16,
64
+ paddingBottom: 12,
65
+ flexDirection: 'row',
66
+ alignItems: 'center',
67
+ gap: 10,
68
+ }}
69
+ accessibilityRole="toolbar"
70
+ testID="input-bar"
71
+ >
72
+ <View style={{ minWidth: 80, flexDirection: 'column' }}>
73
+ <Text
74
+ style={{
75
+ fontSize: 12,
76
+ fontWeight: '700',
77
+ fontFamily: '"Space Grotesk", sans-serif',
78
+ color: TEXT_PRIMARY,
79
+ }}
80
+ testID="input-bar-exercise-name"
81
+ numberOfLines={1}
82
+ >
83
+ {exerciseName}
84
+ </Text>
85
+ <Text
86
+ style={{
87
+ fontSize: 10,
88
+ fontFamily: 'Inter, sans-serif',
89
+ color: TEXT_TERTIARY,
90
+ }}
91
+ testID="input-bar-set-info"
92
+ >
93
+ {setLabel}
94
+ </Text>
95
+ </View>
96
+
97
+ <View className="flex-1 flex-row items-center" style={{ gap: 4 }}>
98
+ <TextInput
99
+ value={reps}
100
+ onChangeText={onRepsChange}
101
+ style={{ ...inputStyle, width: 36 }}
102
+ accessibilityLabel="Reps"
103
+ testID="input-bar-reps"
104
+ keyboardType="numeric"
105
+ />
106
+ <Text
107
+ style={{
108
+ fontSize: 12,
109
+ fontFamily: 'Inter, sans-serif',
110
+ color: TEXT_TERTIARY,
111
+ }}
112
+ >
113
+ {'\u00D7'}
114
+ </Text>
115
+ <TextInput
116
+ value={weight}
117
+ onChangeText={onWeightChange}
118
+ style={{ ...inputStyle, width: 52 }}
119
+ accessibilityLabel={`Weight in ${unit}`}
120
+ testID="input-bar-weight"
121
+ keyboardType="numeric"
122
+ />
123
+ <Text
124
+ style={{
125
+ fontSize: 12,
126
+ fontWeight: '500',
127
+ fontFamily: 'Inter, sans-serif',
128
+ color: TEXT_TERTIARY,
129
+ }}
130
+ testID="input-bar-unit"
131
+ >
132
+ {unit}
133
+ </Text>
134
+ </View>
135
+
136
+ <Pressable
137
+ onPress={onRecord}
138
+ disabled={!canRecord}
139
+ style={({ pressed }) => ({
140
+ backgroundColor: BRAND_PRIMARY,
141
+ borderRadius: 8,
142
+ paddingVertical: 10,
143
+ paddingHorizontal: 16,
144
+ opacity: !canRecord ? 0.4 : pressed ? 0.8 : 1,
145
+ })}
146
+ accessibilityRole="button"
147
+ accessibilityLabel="Record set"
148
+ testID="input-bar-record"
149
+ >
150
+ <Text
151
+ style={{
152
+ fontSize: 13,
153
+ fontWeight: '700',
154
+ fontFamily: 'Inter, sans-serif',
155
+ color: '#FFFFFF',
156
+ }}
157
+ >
158
+ Record
159
+ </Text>
160
+ </Pressable>
161
+ </View>
162
+ )
163
+ }
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { IntensityBar } from './IntensityBar'
4
+
5
+ const meta: Meta<typeof IntensityBar> = {
6
+ title: 'Custom/Workout/IntensityBar',
7
+ component: IntensityBar,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ level: {
11
+ control: { type: 'range', min: 0, max: 1, step: 0.05 },
12
+ description: 'Intensity level (0-1)',
13
+ },
14
+ threshold: {
15
+ control: { type: 'range', min: 0, max: 1, step: 0.05 },
16
+ description: 'MRV/overexertion threshold position (0-1)',
17
+ },
18
+ orientation: {
19
+ control: { type: 'radio' },
20
+ options: ['vertical', 'horizontal'],
21
+ description: 'Bar orientation',
22
+ },
23
+ size: { control: 'number', description: 'Length along the fill axis (px)' },
24
+ thickness: { control: 'number', description: 'Bar thickness (px)' },
25
+ showThresholdLabel: { control: 'boolean', description: 'Show MRV label' },
26
+ },
27
+ }
28
+
29
+ export default meta
30
+ type Story = StoryObj<typeof IntensityBar>
31
+
32
+ export const Low: Story = {
33
+ args: { level: 0.3, threshold: 0.85, showThresholdLabel: true },
34
+ }
35
+
36
+ export const Moderate: Story = {
37
+ args: { level: 0.55, threshold: 0.85, showThresholdLabel: true },
38
+ }
39
+
40
+ export const High: Story = {
41
+ args: { level: 0.85, threshold: 0.85, showThresholdLabel: true },
42
+ }
43
+
44
+ export const Horizontal: Story = {
45
+ args: { level: 0.6, threshold: 0.85, orientation: 'horizontal', size: 120 },
46
+ }
47
+
48
+ export const AllZones: Story = {
49
+ render: () => (
50
+ <View style={{ flexDirection: 'row', gap: 16, alignItems: 'flex-end', padding: 16 }}>
51
+ <IntensityBar level={0.2} threshold={0.85} showThresholdLabel />
52
+ <IntensityBar level={0.35} threshold={0.85} showThresholdLabel />
53
+ <IntensityBar level={0.5} threshold={0.85} showThresholdLabel />
54
+ <IntensityBar level={0.65} threshold={0.85} showThresholdLabel />
55
+ <IntensityBar level={0.75} threshold={0.85} showThresholdLabel />
56
+ <IntensityBar level={0.9} threshold={0.85} showThresholdLabel />
57
+ <IntensityBar level={1.0} threshold={0.85} showThresholdLabel />
58
+ </View>
59
+ ),
60
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { IntensityBar } from './IntensityBar'
5
+
6
+ describe('IntensityBar', () => {
7
+ it('renders the bar', () => {
8
+ render(<IntensityBar level={0.5} />)
9
+ expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders fill element', () => {
13
+ render(<IntensityBar level={0.5} />)
14
+ expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
15
+ })
16
+
17
+ it('renders threshold line when threshold is provided', () => {
18
+ render(<IntensityBar level={0.5} threshold={0.85} />)
19
+ expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
20
+ })
21
+
22
+ it('does not render threshold line when threshold is omitted', () => {
23
+ render(<IntensityBar level={0.5} />)
24
+ expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
25
+ })
26
+
27
+ it('shows MRV label when showThresholdLabel is true and threshold set', () => {
28
+ render(<IntensityBar level={0.6} threshold={0.85} showThresholdLabel />)
29
+ expect(screen.getByText('MRV')).toBeInTheDocument()
30
+ })
31
+
32
+ it('hides MRV label when showThresholdLabel is false', () => {
33
+ render(<IntensityBar level={0.6} threshold={0.85} />)
34
+ expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
35
+ })
36
+
37
+ it('hides MRV label when threshold is omitted even if showThresholdLabel is true', () => {
38
+ render(<IntensityBar level={0.6} showThresholdLabel />)
39
+ expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('has correct accessibility role', () => {
43
+ render(<IntensityBar level={0.5} />)
44
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
45
+ })
46
+
47
+ it('maps level 0-1 to percentage in accessibility label', () => {
48
+ render(<IntensityBar level={0.75} />)
49
+ expect(screen.getByLabelText('Intensity level: 75%')).toBeInTheDocument()
50
+ })
51
+
52
+ it('clamps level above 1 to 100%', () => {
53
+ render(<IntensityBar level={1.5} />)
54
+ expect(screen.getByLabelText('Intensity level: 100%')).toBeInTheDocument()
55
+ })
56
+
57
+ it('clamps level below 0 to 0%', () => {
58
+ render(<IntensityBar level={-0.5} />)
59
+ expect(screen.getByLabelText('Intensity level: 0%')).toBeInTheDocument()
60
+ })
61
+
62
+ it('renders horizontal orientation', () => {
63
+ render(<IntensityBar level={0.5} orientation="horizontal" />)
64
+ expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
65
+ })
66
+
67
+ it('accepts custom size and thickness', () => {
68
+ render(<IntensityBar level={0.5} size={32} thickness={8} />)
69
+ expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
70
+ })
71
+
72
+ describe('fill zones', () => {
73
+ it('renders teal fill for low levels (0-0.4)', () => {
74
+ render(<IntensityBar level={0.3} />)
75
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
76
+ })
77
+
78
+ it('renders amber fill for moderate levels (0.4-0.7)', () => {
79
+ render(<IntensityBar level={0.5} />)
80
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
81
+ })
82
+
83
+ it('renders red fill for high levels (0.7-1.0)', () => {
84
+ render(<IntensityBar level={0.85} />)
85
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
86
+ })
87
+
88
+ it('uses teal at the lower zone boundary', () => {
89
+ render(<IntensityBar level={0.39} />)
90
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
91
+ })
92
+
93
+ it('uses amber at the 0.4 boundary', () => {
94
+ render(<IntensityBar level={0.4} />)
95
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
96
+ })
97
+
98
+ it('uses red at the 0.7 boundary', () => {
99
+ render(<IntensityBar level={0.7} />)
100
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
101
+ })
102
+ })
103
+
104
+ describe('accessibility', () => {
105
+ it('has no accessibility violations', async () => {
106
+ const { container } = render(
107
+ <IntensityBar level={0.5} threshold={0.85} showThresholdLabel />,
108
+ )
109
+ const results = await axe(container)
110
+ expect(results).toHaveNoViolations()
111
+ })
112
+ })
113
+ })
@@ -0,0 +1,166 @@
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, Animated, type ViewProps, type ViewStyle } from 'react-native'
4
+
5
+ export type IntensityBarOrientation = 'vertical' | 'horizontal'
6
+
7
+ export interface IntensityBarProps extends ViewProps {
8
+ /** Current intensity level 0-1 */
9
+ level: number
10
+ /** MRV/overexertion threshold position 0-1 */
11
+ threshold?: number
12
+ /** Orientation */
13
+ orientation?: IntensityBarOrientation
14
+ /** Height for vertical (default 24px) or width for horizontal */
15
+ size?: number
16
+ /** Width for vertical (default 6px) or height for horizontal */
17
+ thickness?: number
18
+ /** Show threshold label ("MRV") */
19
+ showThresholdLabel?: boolean
20
+ className?: string
21
+ }
22
+
23
+ const TRACK_BG = 'rgba(255,255,255,0.06)'
24
+ const THRESHOLD_COLOR = '#FFFFFF'
25
+ const LABEL_COLOR = '#6B7280'
26
+
27
+ /** Fill color by intensity zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
28
+ function getZoneColor(level: number): string {
29
+ if (level >= 0.7) return '#D14343'
30
+ if (level >= 0.4) return '#FFB020'
31
+ return '#14B8A6'
32
+ }
33
+
34
+ function clamp01(value: number): number {
35
+ if (value < 0) return 0
36
+ if (value > 1) return 1
37
+ return value
38
+ }
39
+
40
+ export function IntensityBar({
41
+ level,
42
+ threshold,
43
+ orientation = 'vertical',
44
+ size = 24,
45
+ thickness = 6,
46
+ showThresholdLabel,
47
+ className,
48
+ ...props
49
+ }: IntensityBarProps) {
50
+ const isVertical = orientation === 'vertical'
51
+ const clampedLevel = clamp01(level)
52
+ const percentage = Math.round(clampedLevel * 100)
53
+ const zoneColor = getZoneColor(clampedLevel)
54
+
55
+ const [fillAnim] = useState(() => new Animated.Value(clampedLevel))
56
+
57
+ useEffect(() => {
58
+ const animation = Animated.timing(fillAnim, {
59
+ toValue: clampedLevel,
60
+ duration: 250,
61
+ useNativeDriver: false,
62
+ })
63
+ animation.start()
64
+ return () => animation.stop()
65
+ }, [clampedLevel, fillAnim])
66
+
67
+ const fillSizePercent = fillAnim.interpolate({
68
+ inputRange: [0, 1],
69
+ outputRange: ['0%', '100%'],
70
+ })
71
+
72
+ const trackStyle = isVertical
73
+ ? { width: thickness, height: size }
74
+ : { width: size, height: thickness }
75
+
76
+ const fillStyle = isVertical
77
+ ? {
78
+ position: 'absolute' as const,
79
+ bottom: 0,
80
+ left: 0,
81
+ right: 0,
82
+ height: fillSizePercent,
83
+ }
84
+ : {
85
+ position: 'absolute' as const,
86
+ top: 0,
87
+ bottom: 0,
88
+ left: 0,
89
+ width: fillSizePercent,
90
+ }
91
+
92
+ const thresholdLineStyle: ViewStyle | undefined =
93
+ threshold != null
94
+ ? isVertical
95
+ ? {
96
+ position: 'absolute' as const,
97
+ bottom: `${clamp01(threshold) * 100}%`,
98
+ left: -2,
99
+ right: -2,
100
+ height: 1,
101
+ backgroundColor: THRESHOLD_COLOR,
102
+ zIndex: 4,
103
+ }
104
+ : {
105
+ position: 'absolute' as const,
106
+ left: `${clamp01(threshold) * 100}%`,
107
+ top: -2,
108
+ bottom: -2,
109
+ width: 1,
110
+ backgroundColor: THRESHOLD_COLOR,
111
+ zIndex: 4,
112
+ }
113
+ : undefined
114
+
115
+ return (
116
+ <View
117
+ className={className}
118
+ style={{ alignItems: 'center' }}
119
+ accessibilityRole="progressbar"
120
+ accessibilityValue={{ min: 0, max: 100, now: percentage }}
121
+ accessibilityLabel={`Intensity level: ${percentage}%`}
122
+ testID="intensity-bar"
123
+ {...props}
124
+ >
125
+ <View
126
+ style={{
127
+ ...trackStyle,
128
+ borderRadius: 3,
129
+ backgroundColor: TRACK_BG,
130
+ position: 'relative',
131
+ overflow: 'visible',
132
+ }}
133
+ accessibilityElementsHidden
134
+ >
135
+ <Animated.View
136
+ style={{
137
+ ...fillStyle,
138
+ borderRadius: 3,
139
+ backgroundColor: zoneColor,
140
+ zIndex: 2,
141
+ }}
142
+ testID="intensity-fill"
143
+ />
144
+
145
+ {threshold != null && (
146
+ <View style={thresholdLineStyle} testID="intensity-threshold" />
147
+ )}
148
+ </View>
149
+ {showThresholdLabel && threshold != null && (
150
+ <Text
151
+ style={{
152
+ fontSize: 8,
153
+ fontWeight: '500',
154
+ color: LABEL_COLOR,
155
+ fontFamily: 'Inter, sans-serif',
156
+ marginTop: 6,
157
+ }}
158
+ accessibilityElementsHidden
159
+ testID="intensity-threshold-label"
160
+ >
161
+ MRV
162
+ </Text>
163
+ )}
164
+ </View>
165
+ )
166
+ }
@@ -0,0 +1,152 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View } from 'react-native'
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
+ intensityThreshold: 0.85,
17
+ },
18
+ {
19
+ weekNumber: 2,
20
+ totalWeeks: 4,
21
+ workouts: [
22
+ { name: 'Upper', status: 'current' },
23
+ { name: 'Lower', status: 'upcoming' },
24
+ ],
25
+ intensityLevel: 0.6,
26
+ intensityThreshold: 0.85,
27
+ },
28
+ {
29
+ weekNumber: 3,
30
+ totalWeeks: 4,
31
+ workouts: [
32
+ { name: 'Upper', status: 'upcoming' },
33
+ { name: 'Lower', status: 'upcoming' },
34
+ ],
35
+ intensityLevel: 0.8,
36
+ intensityThreshold: 0.85,
37
+ },
38
+ {
39
+ weekNumber: 4,
40
+ totalWeeks: 4,
41
+ workouts: [
42
+ { name: 'Upper', status: 'upcoming' },
43
+ { name: 'Lower', status: 'upcoming' },
44
+ ],
45
+ intensityLevel: 0.35,
46
+ isDeload: true,
47
+ },
48
+ ]
49
+
50
+ const meta: Meta<typeof MesoCard> = {
51
+ title: 'Custom/Workout/MesoCard',
52
+ component: MesoCard,
53
+ tags: ['autodocs'],
54
+ argTypes: {
55
+ goal: {
56
+ control: 'select',
57
+ options: ['Hypertrophy', 'Strength', 'Peaking'],
58
+ description: 'Training goal shown in the badge',
59
+ },
60
+ expanded: {
61
+ control: 'boolean',
62
+ description: 'Whether the week list is revealed',
63
+ },
64
+ highlighted: {
65
+ control: 'boolean',
66
+ description: 'Synced highlight with the MesoProgressBar',
67
+ },
68
+ },
69
+ }
70
+
71
+ export default meta
72
+ type Story = StoryObj<typeof MesoCard>
73
+
74
+ export const Collapsed: Story = {
75
+ args: {
76
+ name: 'Accumulation',
77
+ goal: 'Hypertrophy',
78
+ split: 'Upper/Lower',
79
+ weekRange: 'Weeks 1-4',
80
+ currentWeek: 2,
81
+ totalWeeks: 4,
82
+ weeks,
83
+ volumeHeatmap: [
84
+ { group: 'chest', percentage: 80 },
85
+ { group: 'back', percentage: 70 },
86
+ { group: 'legs', percentage: 90 },
87
+ { group: 'shoulders', percentage: 45 },
88
+ { group: 'arms', percentage: 35 },
89
+ ],
90
+ },
91
+ }
92
+
93
+ export const Expanded: Story = {
94
+ args: {
95
+ ...Collapsed.args,
96
+ expanded: true,
97
+ },
98
+ }
99
+
100
+ export const Highlighted: Story = {
101
+ args: {
102
+ ...Collapsed.args,
103
+ expanded: true,
104
+ highlighted: true,
105
+ },
106
+ }
107
+
108
+ export const StrengthBlock: Story = {
109
+ args: {
110
+ name: 'Intensification',
111
+ goal: 'Strength',
112
+ split: 'Push/Pull/Legs',
113
+ weekRange: 'Weeks 5-8',
114
+ currentWeek: 6,
115
+ totalWeeks: 4,
116
+ weeks: weeks.map((w) => ({ ...w, weekNumber: w.weekNumber + 4 })),
117
+ volumeHeatmap: [
118
+ { group: 'chest', percentage: 60 },
119
+ { group: 'back', percentage: 65 },
120
+ { group: 'legs', percentage: 75 },
121
+ ],
122
+ expanded: true,
123
+ },
124
+ }
125
+
126
+ function InteractiveMesoCard() {
127
+ const [expanded, setExpanded] = useState(false)
128
+ return (
129
+ <View style={{ maxWidth: 420, padding: 16 }}>
130
+ <MesoCard
131
+ name="Accumulation"
132
+ goal="Hypertrophy"
133
+ split="Upper/Lower"
134
+ weekRange="Weeks 1-4"
135
+ currentWeek={2}
136
+ totalWeeks={4}
137
+ weeks={weeks}
138
+ volumeHeatmap={[
139
+ { group: 'chest', percentage: 80 },
140
+ { group: 'back', percentage: 70 },
141
+ { group: 'legs', percentage: 90 },
142
+ ]}
143
+ expanded={expanded}
144
+ onToggle={() => setExpanded((v) => !v)}
145
+ />
146
+ </View>
147
+ )
148
+ }
149
+
150
+ export const Interactive: Story = {
151
+ render: () => <InteractiveMesoCard />,
152
+ }