@titan-design/react-ui 0.2.6 → 0.3.0

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 (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { StatusDot } from './StatusDot'
5
+
6
+ describe('StatusDot', () => {
7
+ it('renders a dot', () => {
8
+ render(<StatusDot variant="success" />)
9
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
10
+ })
11
+
12
+ it('shows icon at md size', () => {
13
+ render(<StatusDot variant="success" size="md" icon="check" />)
14
+ expect(screen.getByText('\u2713')).toBeInTheDocument()
15
+ })
16
+
17
+ it('does not show icon at sm size', () => {
18
+ render(<StatusDot variant="success" size="sm" icon="check" />)
19
+ expect(screen.queryByText('\u2713')).not.toBeInTheDocument()
20
+ })
21
+
22
+ it('renders exclamation icon', () => {
23
+ render(<StatusDot variant="warning" size="md" icon="exclamation" />)
24
+ expect(screen.getByText('!')).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders dash icon', () => {
28
+ render(<StatusDot variant="error" size="md" icon="dash" />)
29
+ expect(screen.getByText('\u2014')).toBeInTheDocument()
30
+ })
31
+
32
+ it('renders label text when provided', () => {
33
+ render(<StatusDot variant="success" label="On track" />)
34
+ expect(screen.getByText('On track')).toBeInTheDocument()
35
+ })
36
+
37
+ it('does not render label when omitted', () => {
38
+ render(<StatusDot variant="success" />)
39
+ expect(screen.queryByText('On track')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('defaults to sm size', () => {
43
+ render(<StatusDot variant="neutral" />)
44
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
45
+ })
46
+
47
+ it('sets accessibility label with variant', () => {
48
+ render(<StatusDot variant="warning" />)
49
+ expect(screen.getByLabelText('warning status')).toBeInTheDocument()
50
+ })
51
+
52
+ describe('solid variants', () => {
53
+ it('renders all solid variant types without error', () => {
54
+ const { rerender } = render(<StatusDot variant="success" />)
55
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
56
+
57
+ rerender(<StatusDot variant="warning" />)
58
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
59
+
60
+ rerender(<StatusDot variant="error" />)
61
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
62
+
63
+ rerender(<StatusDot variant="neutral" />)
64
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('ring variants', () => {
69
+ it('renders on-track variant', () => {
70
+ render(<StatusDot variant="on-track" />)
71
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
72
+ expect(screen.getByLabelText('on-track status')).toBeInTheDocument()
73
+ })
74
+
75
+ it('renders deviation variant', () => {
76
+ render(<StatusDot variant="deviation" />)
77
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
78
+ expect(screen.getByLabelText('deviation status')).toBeInTheDocument()
79
+ })
80
+
81
+ it('renders future variant', () => {
82
+ render(<StatusDot variant="future" />)
83
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
84
+ expect(screen.getByLabelText('future status')).toBeInTheDocument()
85
+ })
86
+
87
+ it('renders the future variant with a 1px dashed border', () => {
88
+ render(<StatusDot variant="future" />)
89
+ const dot = screen.getByTestId('status-dot')
90
+ expect(dot.style.borderTopStyle).toBe('dashed')
91
+ expect(dot.style.borderTopWidth).toBe('1px')
92
+ })
93
+ })
94
+
95
+ describe('md size', () => {
96
+ it('renders at md size without error', () => {
97
+ render(<StatusDot variant="success" size="md" />)
98
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
99
+ })
100
+ })
101
+
102
+ describe('glow', () => {
103
+ it('applies a box-shadow when glow is set on a solid variant', () => {
104
+ render(<StatusDot variant="success" glow />)
105
+ const dot = screen.getByTestId('status-dot')
106
+ expect(dot.style.boxShadow).toContain('rgba(20,184,166,0.4)')
107
+ })
108
+
109
+ it('applies a box-shadow when glow is set on a ring variant', () => {
110
+ render(<StatusDot variant="on-track" glow />)
111
+ const dot = screen.getByTestId('status-dot')
112
+ expect(dot.style.boxShadow).toContain('rgba(20,184,166,0.4)')
113
+ })
114
+
115
+ it('applies a gray box-shadow when glow is set on the future variant', () => {
116
+ render(<StatusDot variant="future" glow />)
117
+ const dot = screen.getByTestId('status-dot')
118
+ expect(dot.style.boxShadow).toContain('rgba(107,114,128,0.4)')
119
+ })
120
+
121
+ it('does not apply a box-shadow when glow is not set', () => {
122
+ render(<StatusDot variant="on-track" />)
123
+ const dot = screen.getByTestId('status-dot')
124
+ expect(dot.style.boxShadow).toBe('')
125
+ })
126
+ })
127
+
128
+ describe('accessibility', () => {
129
+ it('has no accessibility violations', async () => {
130
+ const { container } = render(
131
+ <StatusDot variant="success" size="md" icon="check" label="On track" />,
132
+ )
133
+ const results = await axe(container)
134
+ expect(results).toHaveNoViolations()
135
+ })
136
+
137
+ it('has no accessibility violations for ring variants', async () => {
138
+ const { container } = render(
139
+ <StatusDot variant="on-track" size="md" icon="check" label="On track" />,
140
+ )
141
+ const results = await axe(container)
142
+ expect(results).toHaveNoViolations()
143
+ })
144
+
145
+ it('has no accessibility violations without label', async () => {
146
+ const { container } = render(<StatusDot variant="warning" size="md" />)
147
+ const results = await axe(container)
148
+ expect(results).toHaveNoViolations()
149
+ })
150
+
151
+ it('has no accessibility violations for all solid variants', async () => {
152
+ for (const variant of ['success', 'warning', 'error', 'neutral'] as const) {
153
+ const { container, unmount } = render(<StatusDot variant={variant} />)
154
+ const results = await axe(container)
155
+ expect(results).toHaveNoViolations()
156
+ unmount()
157
+ }
158
+ })
159
+
160
+ it('sets combined accessibility label when label prop is provided', () => {
161
+ render(<StatusDot variant="success" label="On track" />)
162
+ expect(screen.getByLabelText('success status: On track')).toBeInTheDocument()
163
+ })
164
+ })
165
+ })
@@ -0,0 +1,160 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+
4
+ export type StatusDotVariant =
5
+ | 'success'
6
+ | 'warning'
7
+ | 'error'
8
+ | 'neutral'
9
+ | 'on-track'
10
+ | 'deviation'
11
+ | 'future'
12
+
13
+ export interface StatusDotProps extends ViewProps {
14
+ variant: StatusDotVariant
15
+ /** Center glyph. Only rendered at size="md"; the 8px "sm" dot is too small to fit one. */
16
+ icon?: 'check' | 'exclamation' | 'dash'
17
+ size?: 'sm' | 'md'
18
+ /** Adds a subtle glow effect in the variant color */
19
+ glow?: boolean
20
+ label?: string
21
+ className?: string
22
+ }
23
+
24
+ const solidVariantColors: Record<string, string> = {
25
+ success: '#14B8A6',
26
+ warning: '#FFB020',
27
+ error: '#D14343',
28
+ neutral: '#6B7280',
29
+ }
30
+
31
+ const ringVariantStyles: Record<string, Record<string, unknown>> = {
32
+ 'on-track': {
33
+ backgroundColor: 'rgba(20,184,166,0.15)',
34
+ borderWidth: 1,
35
+ borderColor: 'rgba(20,184,166,0.3)',
36
+ },
37
+ deviation: {
38
+ backgroundColor: 'rgba(245,158,11,0.15)',
39
+ borderWidth: 1,
40
+ borderColor: 'rgba(245,158,11,0.25)',
41
+ },
42
+ future: {
43
+ backgroundColor: 'rgba(107,114,128,0.1)',
44
+ borderWidth: 1,
45
+ borderStyle: 'dashed',
46
+ borderColor: 'rgba(107,114,128,0.2)',
47
+ },
48
+ }
49
+
50
+ const glowStyles: Record<StatusDotVariant, Record<string, unknown>> = {
51
+ success: { boxShadow: '0 0 4px rgba(20,184,166,0.4)' },
52
+ warning: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
53
+ error: { boxShadow: '0 0 4px rgba(239,68,68,0.4)' },
54
+ neutral: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
55
+ 'on-track': { boxShadow: '0 0 4px rgba(20,184,166,0.4)' },
56
+ deviation: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
57
+ future: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
58
+ }
59
+
60
+ const iconChars: Record<string, string> = {
61
+ check: '\u2713',
62
+ exclamation: '!',
63
+ dash: '\u2014',
64
+ }
65
+
66
+ /** Color-matched icon colors: dark on solid bg, light on transparent bg */
67
+ const iconColors: Record<StatusDotVariant, string> = {
68
+ success: '#0A5C52',
69
+ warning: '#6B4000',
70
+ error: '#5C1A1A',
71
+ neutral: '#D1D5DB',
72
+ 'on-track': '#14B8A6',
73
+ deviation: '#FFB020',
74
+ future: '#6B7280',
75
+ }
76
+
77
+ const sizeDimensions = {
78
+ sm: { size: 8, showIcon: false },
79
+ md: { size: 18, showIcon: true },
80
+ }
81
+
82
+ function isSolidVariant(variant: StatusDotVariant): boolean {
83
+ return variant in solidVariantColors
84
+ }
85
+
86
+ export function StatusDot({
87
+ variant,
88
+ icon,
89
+ size = 'sm',
90
+ glow,
91
+ label,
92
+ className,
93
+ ...props
94
+ }: StatusDotProps) {
95
+ const config = sizeDimensions[size]
96
+ const solid = isSolidVariant(variant)
97
+ const ring = ringVariantStyles[variant]
98
+
99
+ const glowStyle = glow ? glowStyles[variant] : null
100
+
101
+ const dot = (
102
+ <View
103
+ style={[
104
+ {
105
+ width: config.size,
106
+ height: config.size,
107
+ borderRadius: 9999,
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ flexShrink: 0,
111
+ },
112
+ solid ? { backgroundColor: solidVariantColors[variant] } : undefined,
113
+ ring ? (ring as Record<string, unknown>) : undefined,
114
+ glowStyle ? (glowStyle as Record<string, unknown>) : undefined,
115
+ ]}
116
+ accessibilityRole="image"
117
+ accessibilityLabel={`${variant} status`}
118
+ testID="status-dot"
119
+ >
120
+ {config.showIcon && icon && (
121
+ <Text
122
+ style={{
123
+ fontSize: 11,
124
+ lineHeight: 11,
125
+ fontWeight: '900',
126
+ color: iconColors[variant],
127
+ }}
128
+ accessibilityElementsHidden
129
+ >
130
+ {iconChars[icon]}
131
+ </Text>
132
+ )}
133
+ </View>
134
+ )
135
+
136
+ if (label) {
137
+ return (
138
+ <View
139
+ className={className}
140
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}
141
+ accessibilityLabel={`${variant} status: ${label}`}
142
+ {...props}
143
+ >
144
+ <View accessibilityElementsHidden>{dot}</View>
145
+ <Text
146
+ style={{ fontSize: 12, lineHeight: 16, color: '#9CA3AF' }}
147
+ accessibilityElementsHidden
148
+ >
149
+ {label}
150
+ </Text>
151
+ </View>
152
+ )
153
+ }
154
+
155
+ return (
156
+ <View className={className} {...props}>
157
+ {dot}
158
+ </View>
159
+ )
160
+ }
@@ -0,0 +1,130 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { View, Text } from 'react-native'
4
+ import { StrengthTrendChart } from './StrengthTrendChart'
5
+ import type { StrengthTrendDataPoint } from './StrengthTrendChart'
6
+
7
+ const actual: StrengthTrendDataPoint[] = [
8
+ { date: '2026-01-06', e1rm: 215, sessionLabel: 'Jan 6' },
9
+ { date: '2026-01-20', e1rm: 222, sessionLabel: 'Jan 20' },
10
+ { date: '2026-02-03', e1rm: 228, isPR: true, sessionLabel: 'Feb 3' },
11
+ { date: '2026-02-17', e1rm: 226, sessionLabel: 'Feb 17' },
12
+ { date: '2026-03-03', e1rm: 235, sessionLabel: 'Mar 3' },
13
+ { date: '2026-03-17', e1rm: 244, isPR: true, sessionLabel: 'Mar 17' },
14
+ ]
15
+
16
+ const projection: StrengthTrendDataPoint[] = [
17
+ { date: '2026-03-17', e1rm: 244 },
18
+ { date: '2026-03-31', e1rm: 252 },
19
+ { date: '2026-04-14', e1rm: 261 },
20
+ ]
21
+
22
+ const mesoBoundaries = [
23
+ { date: '2026-02-03', label: 'Intensify' },
24
+ { date: '2026-03-17', label: 'Peak' },
25
+ ]
26
+
27
+ const decline: StrengthTrendDataPoint[] = [
28
+ { date: '2026-03-03', e1rm: 248 },
29
+ { date: '2026-03-10', e1rm: 244 },
30
+ { date: '2026-03-17', e1rm: 239 },
31
+ { date: '2026-03-24', e1rm: 235 },
32
+ ]
33
+
34
+ const meta: Meta<typeof StrengthTrendChart> = {
35
+ title: 'Custom/Workout/StrengthTrendChart',
36
+ component: StrengthTrendChart,
37
+ tags: ['autodocs'],
38
+ argTypes: {
39
+ width: { control: { type: 'range', min: 160, max: 480, step: 10 }, description: 'Plot width in px' },
40
+ height: { control: { type: 'range', min: 100, max: 220, step: 10 }, description: 'Plot height in px (120-160 for compact)' },
41
+ unit: { control: 'inline-radio', options: ['lbs', 'kg'], description: 'Display unit' },
42
+ animateOnMount: { control: 'boolean', description: 'Left-to-right draw animation on mount' },
43
+ },
44
+ }
45
+
46
+ export default meta
47
+ type Story = StoryObj<typeof StrengthTrendChart>
48
+
49
+ export const Default: Story = {
50
+ args: {
51
+ data: actual,
52
+ projection,
53
+ mesoBoundaries,
54
+ width: 340,
55
+ height: 150,
56
+ unit: 'lbs',
57
+ animateOnMount: false,
58
+ },
59
+ }
60
+
61
+ export const Compact: Story = {
62
+ args: {
63
+ ...Default.args,
64
+ width: 240,
65
+ height: 120,
66
+ },
67
+ }
68
+
69
+ export const WithoutProjection: Story = {
70
+ args: {
71
+ ...Default.args,
72
+ projection: undefined,
73
+ mesoBoundaries: undefined,
74
+ },
75
+ }
76
+
77
+ export const Declining: Story = {
78
+ args: {
79
+ data: decline,
80
+ width: 340,
81
+ height: 150,
82
+ unit: 'lbs',
83
+ animateOnMount: false,
84
+ },
85
+ }
86
+
87
+ export const SinglePoint: Story = {
88
+ args: {
89
+ data: [{ date: '2026-03-17', e1rm: 244, isPR: true, sessionLabel: 'Mar 17' }],
90
+ width: 340,
91
+ height: 150,
92
+ unit: 'kg',
93
+ animateOnMount: false,
94
+ },
95
+ }
96
+
97
+ export const Empty: Story = {
98
+ args: {
99
+ data: [],
100
+ width: 340,
101
+ height: 150,
102
+ unit: 'lbs',
103
+ },
104
+ }
105
+
106
+ function InteractiveChart() {
107
+ const [selected, setSelected] = useState<StrengthTrendDataPoint | null>(null)
108
+ return (
109
+ <View style={{ maxWidth: 380, padding: 16 }}>
110
+ <StrengthTrendChart
111
+ data={actual}
112
+ projection={projection}
113
+ mesoBoundaries={mesoBoundaries}
114
+ width={340}
115
+ height={150}
116
+ unit="lbs"
117
+ onPointPress={setSelected}
118
+ />
119
+ <Text style={{ marginTop: 12, fontSize: 12, color: '#9CA3AF' }}>
120
+ {selected
121
+ ? `Tapped ${selected.sessionLabel ?? selected.date}: ${selected.e1rm} lbs`
122
+ : 'Tap a point on the chart.'}
123
+ </Text>
124
+ </View>
125
+ )
126
+ }
127
+
128
+ export const Interactive: Story = {
129
+ render: () => <InteractiveChart />,
130
+ }
@@ -0,0 +1,217 @@
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 { StrengthTrendChart } from './StrengthTrendChart'
5
+ import type { StrengthTrendDataPoint } from './StrengthTrendChart'
6
+
7
+ const data: StrengthTrendDataPoint[] = [
8
+ { date: '2026-01-06', e1rm: 215, sessionLabel: 'Jan 6' },
9
+ { date: '2026-02-03', e1rm: 228, isPR: true, sessionLabel: 'Feb 3' },
10
+ { date: '2026-03-17', e1rm: 244, isPR: true, sessionLabel: 'Mar 17' },
11
+ ]
12
+
13
+ const projection: StrengthTrendDataPoint[] = [
14
+ { date: '2026-03-17', e1rm: 244 },
15
+ { date: '2026-04-14', e1rm: 261 },
16
+ ]
17
+
18
+ const mesoBoundaries = [
19
+ { date: '2026-02-03', label: 'Intensify' },
20
+ { date: '2026-03-17', label: 'Peak' },
21
+ ]
22
+
23
+ const baseProps = {
24
+ data,
25
+ width: 340,
26
+ height: 150,
27
+ unit: 'lbs' as const,
28
+ animateOnMount: false,
29
+ }
30
+
31
+ describe('StrengthTrendChart', () => {
32
+ describe('rendering', () => {
33
+ it('renders the chart container and labelled canvas', () => {
34
+ render(<StrengthTrendChart {...baseProps} />)
35
+ expect(screen.getByTestId('strength-trend-chart')).toBeInTheDocument()
36
+ expect(screen.getByTestId('strength-trend-chart-canvas')).toBeInTheDocument()
37
+ })
38
+
39
+ it('renders a pressable point per actual data point', () => {
40
+ render(<StrengthTrendChart {...baseProps} />)
41
+ expect(screen.getAllByTestId('strength-trend-chart-point')).toHaveLength(3)
42
+ })
43
+
44
+ it('renders the actual line as connecting segments', () => {
45
+ render(<StrengthTrendChart {...baseProps} />)
46
+ // Two segments connect three points.
47
+ expect(
48
+ screen.getAllByTestId('strength-trend-chart-actual-segment'),
49
+ ).toHaveLength(2)
50
+ })
51
+
52
+ it('renders subtle horizontal gridlines', () => {
53
+ render(<StrengthTrendChart {...baseProps} />)
54
+ const grid = screen.getAllByTestId('strength-trend-chart-gridline')
55
+ expect(grid.length).toBeGreaterThanOrEqual(2)
56
+ expect(grid.length).toBeLessThanOrEqual(3)
57
+ })
58
+
59
+ it('renders a star marker for each PR point', () => {
60
+ render(<StrengthTrendChart {...baseProps} />)
61
+ expect(screen.getAllByTestId('strength-trend-chart-pr-star')).toHaveLength(2)
62
+ })
63
+
64
+ it('renders an Actual / Projected / PR legend', () => {
65
+ render(<StrengthTrendChart {...baseProps} />)
66
+ const legend = screen.getByTestId('strength-trend-chart-legend')
67
+ expect(legend).toHaveTextContent('Actual')
68
+ expect(legend).toHaveTextContent('Projected')
69
+ expect(legend).toHaveTextContent('PR')
70
+ })
71
+ })
72
+
73
+ describe('projection and meso boundaries', () => {
74
+ it('renders dashed projection segments when projection is provided', () => {
75
+ render(<StrengthTrendChart {...baseProps} projection={projection} />)
76
+ expect(
77
+ screen.getAllByTestId('strength-trend-chart-projection-segment'),
78
+ ).toHaveLength(1)
79
+ })
80
+
81
+ it('omits projection segments when none provided', () => {
82
+ render(<StrengthTrendChart {...baseProps} />)
83
+ expect(
84
+ screen.queryByTestId('strength-trend-chart-projection-segment'),
85
+ ).not.toBeInTheDocument()
86
+ })
87
+
88
+ it('renders a boundary marker and axis label per meso boundary', () => {
89
+ render(<StrengthTrendChart {...baseProps} mesoBoundaries={mesoBoundaries} />)
90
+ expect(
91
+ screen.getAllByTestId('strength-trend-chart-meso-boundary'),
92
+ ).toHaveLength(2)
93
+ expect(
94
+ screen.getAllByTestId('strength-trend-chart-axis-label'),
95
+ ).toHaveLength(2)
96
+ })
97
+ })
98
+
99
+ describe('trend pill', () => {
100
+ it('shows a positive percentage for an improving series', () => {
101
+ render(<StrengthTrendChart {...baseProps} />)
102
+ const pill = screen.getByTestId('strength-trend-chart-trend-pill')
103
+ expect(pill).toHaveTextContent('this meso')
104
+ expect(pill.textContent?.startsWith('+')).toBe(true)
105
+ })
106
+
107
+ it('shows a negative percentage for a declining series', () => {
108
+ render(
109
+ <StrengthTrendChart
110
+ {...baseProps}
111
+ data={[
112
+ { date: '2026-03-03', e1rm: 248 },
113
+ { date: '2026-03-17', e1rm: 235 },
114
+ ]}
115
+ />,
116
+ )
117
+ const pill = screen.getByTestId('strength-trend-chart-trend-pill')
118
+ expect(pill.textContent?.startsWith('-')).toBe(true)
119
+ })
120
+
121
+ it('stays neutral instead of using the whole series when the current meso has <2 points', () => {
122
+ render(
123
+ <StrengthTrendChart
124
+ {...baseProps}
125
+ data={[
126
+ { date: '2026-01-06', e1rm: 200 },
127
+ { date: '2026-02-01', e1rm: 210 },
128
+ { date: '2026-03-20', e1rm: 250 },
129
+ ]}
130
+ mesoBoundaries={[{ date: '2026-03-01', label: 'Peak' }]}
131
+ />,
132
+ )
133
+ // Only one point falls in the current meso, so the pill must not report
134
+ // the whole-series jump (+25%) under the "this meso" label.
135
+ const pill = screen.getByTestId('strength-trend-chart-trend-pill')
136
+ expect(pill).toHaveTextContent('+0.0% this meso')
137
+ })
138
+ })
139
+
140
+ describe('interaction', () => {
141
+ it('fires onPointPress with the tapped point', () => {
142
+ const onPointPress = vi.fn()
143
+ render(<StrengthTrendChart {...baseProps} onPointPress={onPointPress} />)
144
+ fireEvent.click(screen.getAllByTestId('strength-trend-chart-point')[1])
145
+ expect(onPointPress).toHaveBeenCalledWith(
146
+ expect.objectContaining({ e1rm: 228 }),
147
+ )
148
+ })
149
+
150
+ it('shows a tooltip with value and unit after a point is tapped', () => {
151
+ render(<StrengthTrendChart {...baseProps} />)
152
+ expect(
153
+ screen.queryByTestId('strength-trend-chart-tooltip'),
154
+ ).not.toBeInTheDocument()
155
+ fireEvent.click(screen.getAllByTestId('strength-trend-chart-point')[2])
156
+ const tooltip = screen.getByTestId('strength-trend-chart-tooltip')
157
+ expect(tooltip).toHaveTextContent('244 lbs')
158
+ })
159
+ })
160
+
161
+ describe('empty state', () => {
162
+ it('renders an empty placeholder when there is no data', () => {
163
+ render(<StrengthTrendChart {...baseProps} data={[]} />)
164
+ expect(
165
+ screen.getByTestId('strength-trend-chart-empty'),
166
+ ).toBeInTheDocument()
167
+ expect(
168
+ screen.queryByTestId('strength-trend-chart-canvas'),
169
+ ).not.toBeInTheDocument()
170
+ })
171
+
172
+ it('shows the placeholder when data is empty even if a projection exists', () => {
173
+ render(<StrengthTrendChart {...baseProps} data={[]} projection={projection} />)
174
+ expect(
175
+ screen.getByTestId('strength-trend-chart-empty'),
176
+ ).toBeInTheDocument()
177
+ expect(
178
+ screen.queryByTestId('strength-trend-chart-canvas'),
179
+ ).not.toBeInTheDocument()
180
+ })
181
+ })
182
+
183
+ describe('accessibility', () => {
184
+ it('labels the chart as an image with current value and trend', () => {
185
+ render(<StrengthTrendChart {...baseProps} />)
186
+ const canvas = screen.getByTestId('strength-trend-chart-canvas')
187
+ expect(canvas).toHaveAttribute('role', 'img')
188
+ expect(canvas.getAttribute('aria-label')).toContain('Current: 244 lbs')
189
+ expect(canvas.getAttribute('aria-label')).toContain('Trend:')
190
+ })
191
+
192
+ it('labels the empty state accessibly', () => {
193
+ render(<StrengthTrendChart {...baseProps} data={[]} />)
194
+ expect(
195
+ screen.getByLabelText('Strength trend chart, no data'),
196
+ ).toBeInTheDocument()
197
+ })
198
+
199
+ it('has no accessibility violations', async () => {
200
+ const { container } = render(<StrengthTrendChart {...baseProps} />)
201
+ expect(await axe(container)).toHaveNoViolations()
202
+ })
203
+
204
+ it('has no accessibility violations with all features enabled', async () => {
205
+ const { container } = render(
206
+ <StrengthTrendChart
207
+ {...baseProps}
208
+ projection={projection}
209
+ mesoBoundaries={mesoBoundaries}
210
+ onPointPress={vi.fn()}
211
+ unit="kg"
212
+ />,
213
+ )
214
+ expect(await axe(container)).toHaveNoViolations()
215
+ })
216
+ })
217
+ })