@titan-design/react-ui 0.6.0 → 0.7.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 (248) hide show
  1. package/dist/bodymap.js +12 -7
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +12 -7
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +855 -124
  6. package/dist/index.d.ts +855 -124
  7. package/dist/index.js +2276 -801
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2230 -802
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-MO0JCySL.d.mts → pages-8u_4WbL-.d.mts} +280 -57
  12. package/dist/{pages-D7Jlssvp.d.ts → pages-DaWiBOGa.d.ts} +280 -57
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1255 -798
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1255 -798
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +16 -82
  20. package/dist/theme/index.d.ts +16 -82
  21. package/dist/theme/index.js +13 -115
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +13 -115
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +14 -6
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +14 -6
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +1 -1
  30. package/src/arch/Architecture.stories.tsx +870 -0
  31. package/src/arch/annotations.json +48 -0
  32. package/src/arch/arch-graph.json +4301 -0
  33. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  34. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
  36. package/src/components/custom/Metric/Metric.stories.tsx +1 -1
  37. package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
  38. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  39. package/src/components/custom/Table/Table.stories.tsx +1 -1
  40. package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
  41. package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
  42. package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
  43. package/src/components/custom/TimerReadout/index.ts +1 -0
  44. package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
  45. package/src/components/custom/Typography/Typography.stories.tsx +1 -1
  46. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
  47. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
  48. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +9 -15
  49. package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
  50. package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
  51. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
  52. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
  53. package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
  54. package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
  55. package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
  56. package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
  57. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
  58. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
  59. package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
  60. package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
  61. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
  62. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
  63. package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -5
  64. package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
  65. package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
  66. package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
  67. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
  68. package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
  69. package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
  70. package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
  71. package/src/components/custom/Workout/FatigueMeter.test.tsx +73 -0
  72. package/src/components/custom/Workout/FatigueMeter.tsx +82 -0
  73. package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
  74. package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
  75. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
  76. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
  77. package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
  78. package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
  79. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
  80. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
  81. package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
  82. package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
  83. package/src/components/custom/Workout/MetricTiles.tsx +48 -0
  84. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
  85. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
  86. package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
  87. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
  88. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
  89. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -6
  90. package/src/components/custom/Workout/README.md +146 -4
  91. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
  92. package/src/components/custom/Workout/RestTimer.stories.tsx +1 -1
  93. package/src/components/custom/Workout/RestTimer.tsx +15 -10
  94. package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
  95. package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
  96. package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
  97. package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
  98. package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
  99. package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
  100. package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
  101. package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
  102. package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
  103. package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
  104. package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
  105. package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
  106. package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
  107. package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
  108. package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
  109. package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
  110. package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
  111. package/src/components/custom/Workout/SessionHeader.tsx +152 -0
  112. package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
  113. package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
  114. package/src/components/custom/Workout/SessionRail.tsx +126 -0
  115. package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
  116. package/src/components/custom/Workout/SetBar.test.tsx +123 -0
  117. package/src/components/custom/Workout/SetBar.tsx +171 -0
  118. package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
  119. package/src/components/custom/Workout/SetRow.test.tsx +94 -166
  120. package/src/components/custom/Workout/SetRow.tsx +169 -232
  121. package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
  122. package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
  123. package/src/components/custom/Workout/SetStrip.tsx +71 -0
  124. package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
  125. package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
  126. package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
  127. package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
  128. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
  129. package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
  130. package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
  131. package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
  132. package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
  133. package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
  134. package/src/components/custom/Workout/StatusPill.tsx +88 -0
  135. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
  136. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
  137. package/src/components/custom/Workout/TempoBar.stories.tsx +1 -1
  138. package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
  139. package/src/components/custom/Workout/TempoDisplay.tsx +23 -33
  140. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
  141. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
  142. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
  143. package/src/components/custom/Workout/VelocityStrip.stories.tsx +129 -66
  144. package/src/components/custom/Workout/VelocityStrip.test.tsx +376 -141
  145. package/src/components/custom/Workout/VelocityStrip.tsx +508 -120
  146. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
  147. package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
  148. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
  149. package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
  150. package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
  151. package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
  152. package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
  153. package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
  154. package/src/components/custom/Workout/ZoneTrack.stories.tsx +140 -0
  155. package/src/components/custom/Workout/ZoneTrack.test.tsx +200 -0
  156. package/src/components/custom/Workout/ZoneTrack.tsx +315 -0
  157. package/src/components/custom/Workout/index.ts +55 -2
  158. package/src/components/custom/Workout/metricText.stories.tsx +69 -0
  159. package/src/components/custom/Workout/metricText.tsx +34 -0
  160. package/src/components/custom/Workout/paceTone.test.ts +29 -0
  161. package/src/components/custom/Workout/paceTone.ts +27 -0
  162. package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
  163. package/src/components/custom/index.ts +1 -0
  164. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  165. package/src/components/icons/icons.stories.tsx +67 -2
  166. package/src/components/icons/icons.test.tsx +32 -2
  167. package/src/components/icons/icons.tsx +108 -0
  168. package/src/components/shell/BrandLockup.stories.tsx +3 -3
  169. package/src/components/shell/DashboardShell.stories.tsx +65 -0
  170. package/src/components/shell/DashboardShell.test.tsx +42 -0
  171. package/src/components/shell/DashboardShell.tsx +74 -0
  172. package/src/components/shell/DeviceIndicator.stories.tsx +2 -2
  173. package/src/components/shell/DeviceMenu.stories.tsx +5 -5
  174. package/src/components/shell/DeviceRow.stories.tsx +3 -3
  175. package/src/components/shell/NavItem.stories.tsx +52 -0
  176. package/src/components/shell/NavItem.test.tsx +35 -0
  177. package/src/components/shell/NavItem.tsx +75 -0
  178. package/src/components/shell/README.md +30 -0
  179. package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
  180. package/src/components/shell/SessionStatePill.stories.tsx +3 -3
  181. package/src/components/shell/SessionStatePill.tsx +1 -1
  182. package/src/components/shell/SideNav.stories.tsx +81 -0
  183. package/src/components/shell/SideNav.test.tsx +51 -0
  184. package/src/components/shell/SideNav.tsx +74 -0
  185. package/src/components/shell/TopBar.stories.tsx +5 -5
  186. package/src/components/shell/index.ts +5 -0
  187. package/src/components/ui/alert/Alert.stories.tsx +1 -1
  188. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  189. package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
  190. package/src/components/ui/badge/Badge.stories.tsx +1 -1
  191. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
  192. package/src/components/ui/button/Button.stories.tsx +1 -1
  193. package/src/components/ui/card/Card.stories.tsx +1 -1
  194. package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
  195. package/src/components/ui/chip/Chip.stories.tsx +1 -1
  196. package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
  197. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  198. package/src/components/ui/divider/Divider.stories.tsx +1 -1
  199. package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
  200. package/src/components/ui/form-field/FormField.stories.tsx +1 -1
  201. package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
  202. package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
  203. package/src/components/ui/index.ts +1 -0
  204. package/src/components/ui/indicator/Indicator.stories.tsx +2 -2
  205. package/src/components/ui/indicator/Indicator.test.tsx +1 -0
  206. package/src/components/ui/indicator/Indicator.tsx +6 -1
  207. package/src/components/ui/input/Input.stories.tsx +1 -1
  208. package/src/components/ui/link/Link.stories.tsx +1 -1
  209. package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
  210. package/src/components/ui/menu/Menu.stories.tsx +1 -1
  211. package/src/components/ui/modal/Modal.stories.tsx +1 -1
  212. package/src/components/ui/pill/Pill.stories.tsx +1 -1
  213. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  214. package/src/components/ui/progress/Progress.stories.tsx +1 -1
  215. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  216. package/src/components/ui/section/Section.stories.tsx +1 -1
  217. package/src/components/ui/select/Select.stories.tsx +1 -1
  218. package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
  219. package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
  220. package/src/components/ui/stack/Stack.stories.tsx +1 -1
  221. package/src/components/ui/surface/Surface.stories.tsx +1 -1
  222. package/src/components/ui/switch/Switch.stories.tsx +1 -1
  223. package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
  224. package/src/components/ui/tile/Tile.stories.tsx +79 -0
  225. package/src/components/ui/tile/Tile.test.tsx +48 -0
  226. package/src/components/ui/tile/Tile.tsx +64 -0
  227. package/src/components/ui/tile/index.ts +2 -0
  228. package/src/components/ui/toast/Toast.stories.tsx +1 -1
  229. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  230. package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
  231. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
  232. package/src/hooks/index.ts +2 -0
  233. package/src/hooks/useTimer.test.ts +150 -0
  234. package/src/hooks/useTimer.ts +77 -0
  235. package/src/index.ts +3 -0
  236. package/src/pages.ts +1 -1
  237. package/src/stories/PresetShowcase.stories.tsx +1 -1
  238. package/src/stories/ThemePresets.stories.tsx +1 -1
  239. package/src/theme/ColorSystem.stories.tsx +1 -1
  240. package/src/theme/Colors.stories.tsx +5 -8
  241. package/src/theme/config.ts +4 -0
  242. package/src/theme/elevation.stories.tsx +1 -1
  243. package/src/theme/global.css +5 -1
  244. package/src/theme/shadows.stories.tsx +1 -1
  245. package/src/theme/tokens/primitives.ts +11 -107
  246. package/src/theme/tokens/semantic.ts +8 -0
  247. package/src/theme/workout-tokens.ts +11 -7
  248. package/tailwind.config.js +5 -0
@@ -0,0 +1,93 @@
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 {
5
+ ExerciseIndicator,
6
+ resolveIndicator,
7
+ INDICATOR_PRECEDENCE,
8
+ type ExerciseIndicatorKind,
9
+ } from './ExerciseIndicator'
10
+
11
+ // tier color = titan status token, applied as the glyph's SVG stroke (literal hex):
12
+ // danger=status-error, warning=status-warning, success=status-success, info=status-info.
13
+ const kinds: { kind: ExerciseIndicatorKind; label: string; tier: string }[] = [
14
+ { kind: 'imbalance', label: 'Left/right imbalance', tier: '#D14343' },
15
+ { kind: 'overshoot', label: 'Load overshoot', tier: '#D14343' },
16
+ { kind: 'velocity-loss', label: 'Velocity loss', tier: '#F9B415' },
17
+ { kind: 'missed-reps', label: 'Missed reps', tier: '#F9B415' },
18
+ { kind: 'pr', label: 'Personal record', tier: '#2ED573' },
19
+ { kind: 'info', label: 'More info', tier: '#2196F3' },
20
+ ]
21
+
22
+ describe('ExerciseIndicator', () => {
23
+ it.each(kinds)('renders the $kind chip glyph and accessible label', ({ kind, label }) => {
24
+ const { container } = render(<ExerciseIndicator kind={kind} />)
25
+ // glyph is an inline SVG (lucide-mirrored), not a text character
26
+ expect(container.querySelector('svg')).toBeInTheDocument()
27
+ expect(screen.getByLabelText(label)).toBeInTheDocument()
28
+ })
29
+
30
+ it.each(kinds)('tints the $kind glyph with its tier color', ({ kind, tier }) => {
31
+ const { container } = render(<ExerciseIndicator kind={kind} />)
32
+ expect(container.querySelector('svg')!.getAttribute('stroke')).toBe(tier)
33
+ })
34
+
35
+ it('renders a decorative (aria-hidden) glyph so the chip owns the label', () => {
36
+ const { container } = render(<ExerciseIndicator kind="pr" />)
37
+ expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true')
38
+ })
39
+
40
+ it('is presentational (no button role) without onPress', () => {
41
+ render(<ExerciseIndicator kind="pr" />)
42
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
43
+ })
44
+
45
+ it('becomes a button and fires onPress when interactive', () => {
46
+ const onPress = vi.fn()
47
+ render(<ExerciseIndicator kind="pr" onPress={onPress} />)
48
+ const button = screen.getByRole('button', { name: 'Personal record' })
49
+ fireEvent.click(button)
50
+ expect(onPress).toHaveBeenCalledOnce()
51
+ })
52
+
53
+ describe('accessibility', () => {
54
+ it.each(kinds)('has no accessibility violations for $kind', async ({ kind }) => {
55
+ const { container } = render(<ExerciseIndicator kind={kind} />)
56
+ const results = await axe(container)
57
+ expect(results).toHaveNoViolations()
58
+ })
59
+ })
60
+ })
61
+
62
+ describe('resolveIndicator', () => {
63
+ it('returns undefined when no signals are active', () => {
64
+ expect(resolveIndicator([])).toBeUndefined()
65
+ })
66
+
67
+ it('returns the sole candidate unchanged', () => {
68
+ expect(resolveIndicator(['velocity-loss'])).toBe('velocity-loss')
69
+ })
70
+
71
+ it('picks the highest-precedence kind regardless of input order', () => {
72
+ expect(resolveIndicator(['info', 'pr', 'overshoot'])).toBe('overshoot')
73
+ expect(resolveIndicator(['overshoot', 'pr', 'info'])).toBe('overshoot')
74
+ })
75
+
76
+ it('lets imbalance (rank 1) win over every other signal', () => {
77
+ expect(resolveIndicator([...INDICATOR_PRECEDENCE])).toBe('imbalance')
78
+ })
79
+
80
+ it('ranks the soft alerts above pr (alert > pr)', () => {
81
+ expect(resolveIndicator(['pr', 'missed-reps'])).toBe('missed-reps')
82
+ expect(resolveIndicator(['pr', 'velocity-loss'])).toBe('velocity-loss')
83
+ })
84
+
85
+ it('falls to info only when it is the lowest active signal', () => {
86
+ expect(resolveIndicator(['info', 'pr'])).toBe('pr')
87
+ expect(resolveIndicator(['info'])).toBe('info')
88
+ })
89
+
90
+ it('ignores duplicate candidates', () => {
91
+ expect(resolveIndicator(['pr', 'pr', 'info'])).toBe('pr')
92
+ })
93
+ })
@@ -0,0 +1,120 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import type { ComponentType } from 'react'
3
+ import { View, Pressable, type ViewProps } from 'react-native'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
+ import {
6
+ ScaleIcon,
7
+ AlertTriangleIcon,
8
+ TrendingDownIcon,
9
+ CircleSlashIcon,
10
+ AwardIcon,
11
+ InfoIcon,
12
+ type IconProps,
13
+ } from '../../icons'
14
+
15
+ // Literal-hex status pins (the TempoBar pattern) — `resolveColor()` yields a
16
+ // `var(...)` under the RNW vitest alias, which breaks `toHaveStyle` in tests.
17
+ const t = getSemanticColors('dark')
18
+
19
+ /**
20
+ * The LOCKED 6-kind indicator taxonomy (TD-03.51). One precedence-ranked slot is
21
+ * ever shown per exercise, resolved by {@link resolveIndicator}. Glyph = the
22
+ * specific signal; color = the severity tier (danger/warning/success/info, bound
23
+ * to titan status tokens). Outlined (border + glyph, no fill) so a chip never
24
+ * collides with the filled velocity strip beside it.
25
+ */
26
+ export type ExerciseIndicatorKind =
27
+ | 'imbalance'
28
+ | 'overshoot'
29
+ | 'velocity-loss'
30
+ | 'missed-reps'
31
+ | 'pr'
32
+ | 'info'
33
+
34
+ interface IndicatorConfig {
35
+ Icon: ComponentType<IconProps>
36
+ /** Tier color — a titan status semantic token (literal hex on `t`). */
37
+ color: string
38
+ /** Accessible description (also the intended tooltip subject). */
39
+ label: string
40
+ }
41
+
42
+ const INDICATOR_CONFIG: Record<ExerciseIndicatorKind, IndicatorConfig> = {
43
+ imbalance: { Icon: ScaleIcon, color: t['status-error'], label: 'Left/right imbalance' },
44
+ overshoot: { Icon: AlertTriangleIcon, color: t['status-error'], label: 'Load overshoot' },
45
+ 'velocity-loss': { Icon: TrendingDownIcon, color: t['status-warning'], label: 'Velocity loss' },
46
+ 'missed-reps': { Icon: CircleSlashIcon, color: t['status-warning'], label: 'Missed reps' },
47
+ pr: { Icon: AwardIcon, color: t['status-success'], label: 'Personal record' },
48
+ info: { Icon: InfoIcon, color: t['status-info'], label: 'More info' },
49
+ }
50
+
51
+ /**
52
+ * Precedence order (1=highest → last=lowest). A single chip is shown, so when
53
+ * several signals are true the highest-ranked wins. Alerts outrank `pr`; `info`
54
+ * is the floor. This ordering is LOCKED — do not reshuffle.
55
+ */
56
+ export const INDICATOR_PRECEDENCE: readonly ExerciseIndicatorKind[] = [
57
+ 'imbalance',
58
+ 'overshoot',
59
+ 'velocity-loss',
60
+ 'missed-reps',
61
+ 'pr',
62
+ 'info',
63
+ ]
64
+
65
+ /**
66
+ * Given the signals currently true for an exercise, pick the single
67
+ * highest-precedence kind to surface (or `undefined` when none apply). Order and
68
+ * duplicates in `candidates` don't matter — {@link INDICATOR_PRECEDENCE} decides.
69
+ */
70
+ export function resolveIndicator(
71
+ candidates: ExerciseIndicatorKind[],
72
+ ): ExerciseIndicatorKind | undefined {
73
+ return INDICATOR_PRECEDENCE.find((kind) => candidates.includes(kind))
74
+ }
75
+
76
+ export interface ExerciseIndicatorProps extends Omit<ViewProps, 'children'> {
77
+ kind: ExerciseIndicatorKind
78
+ /** Optional tap handler (surfaces detail — PR history / alert / info). */
79
+ onPress?: () => void
80
+ className?: string
81
+ }
82
+
83
+ /**
84
+ * A small circular outlined chip for an exercise heading. Presentational and
85
+ * static (chips are chrome — no pulse); the tap detail (tooltip/sheet) is wired
86
+ * by the consumer.
87
+ */
88
+ export function ExerciseIndicator({ kind, onPress, className, ...props }: ExerciseIndicatorProps) {
89
+ const { Icon, color, label } = INDICATOR_CONFIG[kind]
90
+
91
+ const chip = (
92
+ <View
93
+ className={className}
94
+ style={{
95
+ width: 16,
96
+ height: 16,
97
+ borderRadius: 8,
98
+ borderWidth: 1,
99
+ borderColor: color,
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ }}
103
+ accessibilityRole="image"
104
+ accessibilityLabel={label}
105
+ testID="exercise-indicator"
106
+ {...props}
107
+ >
108
+ <Icon size={11} color={color} />
109
+ </View>
110
+ )
111
+
112
+ if (onPress) {
113
+ return (
114
+ <Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={label}>
115
+ {chip}
116
+ </Pressable>
117
+ )
118
+ }
119
+ return chip
120
+ }
@@ -0,0 +1,51 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View } from 'react-native'
4
+ import { FatigueMeter } from './FatigueMeter'
5
+
6
+ const meta: Meta<typeof FatigueMeter> = {
7
+ title: 'Workout/DataViz/FatigueMeter',
8
+ component: FatigueMeter,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ value: { control: { type: 'range', min: 0, max: 40, step: 1 } },
12
+ },
13
+ parameters: {
14
+ docs: {
15
+ description: {
16
+ component:
17
+ '**Component.** The winning fatigue-visual lineage: a sliding needle over a fixed ' +
18
+ 'green→gold→orange→red velocity-loss gradient with VL10/VL20/VL30/stop markers. ' +
19
+ 'Composes the [ZoneTrack](?path=/docs/workout-dataviz-zonetrack--docs) primitive. ' +
20
+ 'Prop-driven: `value` plus overridable `thresholds` / `max` / `zoneColors` / `labels`.',
21
+ },
22
+ },
23
+ },
24
+ decorators: [
25
+ (Story) => (
26
+ <View style={{ width: 360, padding: 20, backgroundColor: '#131313' }}>
27
+ <Story />
28
+ </View>
29
+ ),
30
+ ],
31
+ }
32
+ export default meta
33
+
34
+ type Story = StoryObj<typeof FatigueMeter>
35
+
36
+ /** Fresh — needle sits in the green zone. */
37
+ export const Fresh: Story = { args: { value: 5 } }
38
+
39
+ /** VL20 — needle crossing into the orange zone. */
40
+ export const Vl20: Story = { args: { value: 21 } }
41
+
42
+ /** Stop zone — deep into the red. */
43
+ export const StopZone: Story = { args: { value: 34 } }
44
+
45
+ /** Chunky wall-glanceable density via a taller track. */
46
+ export const WallDensity: Story = { args: { value: 21, trackHeight: 22 } }
47
+
48
+ /** Custom scale — a tighter 0..30 domain with earlier thresholds. */
49
+ export const CustomScale: Story = {
50
+ args: { value: 12, max: 30, thresholds: [8, 16, 24], labels: ['ok', 'w1', 'w2', 'w3', 'max'] },
51
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { FatigueMeter } from './FatigueMeter'
5
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
6
+
7
+ const { green, red } = WORKOUT_TOKENS.scale
8
+
9
+ describe('FatigueMeter', () => {
10
+ it('renders the four fatigue zone bands', () => {
11
+ render(<FatigueMeter value={0} />)
12
+ expect(screen.getAllByTestId('zone-track-band')).toHaveLength(4)
13
+ })
14
+
15
+ it('colours the bands from the canonical effort scale', () => {
16
+ render(<FatigueMeter value={0} />)
17
+ const bands = screen.getAllByTestId('zone-track-band')
18
+ expect(bands[0]).toHaveStyle({ backgroundColor: green })
19
+ expect(bands[3]).toHaveStyle({ backgroundColor: red })
20
+ })
21
+
22
+ it('renders the default VL scale labels', () => {
23
+ render(<FatigueMeter value={0} />)
24
+ for (const label of ['fresh', 'VL10', 'VL20', 'VL30', 'stop']) {
25
+ expect(screen.getByText(label)).toBeInTheDocument()
26
+ }
27
+ })
28
+
29
+ it('positions the needle at the value on the 0..40 loss scale', () => {
30
+ render(<FatigueMeter value={20} />)
31
+ expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ left: '50%' })
32
+ })
33
+
34
+ it('clamps a value beyond the stop end to the track end', () => {
35
+ render(<FatigueMeter value={60} />)
36
+ expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ left: '100%' })
37
+ })
38
+
39
+ it('reshapes the zones from custom thresholds + max', () => {
40
+ render(<FatigueMeter value={5} max={20} thresholds={[5, 10, 15]} />)
41
+ const bands = screen.getAllByTestId('zone-track-band')
42
+ expect(bands[0]).toHaveStyle({ flexGrow: 5 })
43
+ expect(bands[3]).toHaveStyle({ flexGrow: 5 })
44
+ })
45
+
46
+ it('honours custom labels', () => {
47
+ render(<FatigueMeter value={5} labels={['A', 'B', 'C', 'D', 'E']} />)
48
+ expect(screen.getByText('A')).toBeInTheDocument()
49
+ expect(screen.getByText('E')).toBeInTheDocument()
50
+ })
51
+
52
+ it('honours a needle colour override', () => {
53
+ render(<FatigueMeter value={10} needleColor="#FF00FF" />)
54
+ expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ backgroundColor: '#FF00FF' })
55
+ })
56
+
57
+ // RNW does not map accessibilityValue → aria-valuenow under jsdom (gotcha #11), so the
58
+ // loss value is verified via the needle position + the accessible name.
59
+ it('exposes the progressbar role with the loss value in its accessible name', () => {
60
+ render(<FatigueMeter value={17} />)
61
+ expect(
62
+ screen.getByRole('progressbar', { name: 'Fatigue: 17% velocity loss' })
63
+ ).toBeInTheDocument()
64
+ })
65
+
66
+ describe('accessibility', () => {
67
+ it('has no accessibility violations', async () => {
68
+ const { container } = render(<FatigueMeter value={21} />)
69
+ const results = await axe(container)
70
+ expect(results).toHaveNoViolations()
71
+ })
72
+ })
73
+ })
@@ -0,0 +1,82 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import type { ViewProps } from 'react-native'
3
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
4
+ import { ZoneTrack, type ZoneTrackZone, type ZoneTrackTick } from './ZoneTrack'
5
+
6
+ const { green, yellow, orange, red } = WORKOUT_TOKENS.scale
7
+
8
+ /** Canonical fatigue band colours (fresh → stop) — the even effort-scale hue walk. */
9
+ const DEFAULT_ZONE_COLORS: [string, string, string, string] = [green, yellow, orange, red]
10
+ /** Zone boundaries in velocity-loss %, matching the VL10/VL20/VL30 coaching cues. */
11
+ const DEFAULT_THRESHOLDS: [number, number, number] = [10, 20, 30]
12
+ /** Scale labels at [min, ...thresholds, max]. */
13
+ const DEFAULT_LABELS = ['fresh', 'VL10', 'VL20', 'VL30', 'stop']
14
+ const DEFAULT_MAX = 40
15
+
16
+ export interface FatigueMeterProps extends ViewProps {
17
+ /** Current velocity-loss percentage (0 = fresh). Drives the needle position. */
18
+ value: number
19
+ /** Upper bound of the fatigue scale — the "stop" end, in loss %. Default 40. */
20
+ max?: number
21
+ /** Zone boundary thresholds (loss %): green→gold, gold→orange, orange→red. Default [10,20,30]. */
22
+ thresholds?: [number, number, number]
23
+ /** Zone band colours low→high (fresh→stop). Default the canonical effort scale. */
24
+ zoneColors?: [string, string, string, string]
25
+ /** Tick labels at [min, ...thresholds, max]. Default ['fresh','VL10','VL20','VL30','stop']. */
26
+ labels?: string[]
27
+ /** Track height in px. Default 14. */
28
+ trackHeight?: number
29
+ /** Needle colour. Default white. */
30
+ needleColor?: string
31
+ className?: string
32
+ }
33
+
34
+ /**
35
+ * A fatigue meter: a sliding needle over a fixed green→gold→orange→red velocity-loss
36
+ * gradient with VL10/VL20/VL30/stop scale markers. This is the winning fatigue-visual
37
+ * lineage (needle-over-zoned-gradient, three independent sources agreed) — it composes
38
+ * the shared {@link ZoneTrack} primitive, supplying the fatigue domain, zone bands from
39
+ * the effort-scale tokens, and the coaching-cue tick labels. Prop-driven: `value` plus
40
+ * overridable `thresholds` / `max` / `zoneColors` / `labels`.
41
+ *
42
+ * @example
43
+ * <FatigueMeter value={21} /> // needle in the VL20 (orange) zone
44
+ * <FatigueMeter value={8} max={40} thresholds={[10, 20, 30]} />
45
+ */
46
+ export function FatigueMeter({
47
+ value,
48
+ max = DEFAULT_MAX,
49
+ thresholds = DEFAULT_THRESHOLDS,
50
+ zoneColors = DEFAULT_ZONE_COLORS,
51
+ labels = DEFAULT_LABELS,
52
+ trackHeight = 14,
53
+ needleColor,
54
+ className,
55
+ ...props
56
+ }: FatigueMeterProps) {
57
+ const [t1, t2, t3] = thresholds
58
+ const zones: ZoneTrackZone[] = [
59
+ { upTo: t1, color: zoneColors[0] },
60
+ { upTo: t2, color: zoneColors[1] },
61
+ { upTo: t3, color: zoneColors[2] },
62
+ { upTo: max, color: zoneColors[3] },
63
+ ]
64
+
65
+ const tickValues = [0, t1, t2, t3, max]
66
+ const ticks: ZoneTrackTick[] = tickValues.map((v, i) => ({ value: v, label: labels[i] }))
67
+
68
+ return (
69
+ <ZoneTrack
70
+ className={className}
71
+ zones={zones}
72
+ min={0}
73
+ max={max}
74
+ trackHeight={trackHeight}
75
+ ticks={ticks}
76
+ marker={{ type: 'needle', value, color: needleColor }}
77
+ accessibilityLabel={`Fatigue: ${value}% velocity loss`}
78
+ testID="fatigue-meter"
79
+ {...props}
80
+ />
81
+ )
82
+ }
@@ -4,7 +4,7 @@ import { View } from 'react-native'
4
4
  import { InputBar } from './InputBar'
5
5
 
6
6
  const meta: Meta<typeof InputBar> = {
7
- title: 'Custom/Workout/InputBar',
7
+ title: 'Workout/InputBar',
8
8
  component: InputBar,
9
9
  tags: ['autodocs'],
10
10
  argTypes: {
@@ -3,7 +3,7 @@ import { View } from 'react-native'
3
3
  import { IntensityBar } from './IntensityBar'
4
4
 
5
5
  const meta: Meta<typeof IntensityBar> = {
6
- title: 'Custom/Workout/IntensityBar',
6
+ title: 'Workout/DataViz/IntensityBar',
7
7
  component: IntensityBar,
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
@@ -0,0 +1,153 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { LiveAuraFrame } from './LiveAuraFrame'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
+
6
+ const t = getSemanticColors('dark')
7
+
8
+ /** A representative live-workout hero so the full-surface flood reads against real content. */
9
+ function LiveHero({ caption }: { caption: string }) {
10
+ return (
11
+ <View style={{ flex: 1, padding: 24, justifyContent: 'space-between' }}>
12
+ <View>
13
+ <Text
14
+ style={{
15
+ fontSize: 11,
16
+ fontWeight: '800',
17
+ letterSpacing: 1.2,
18
+ textTransform: 'uppercase',
19
+ color: t['text-tertiary'],
20
+ }}
21
+ >
22
+ Cable Row · Set 4 / 5
23
+ </Text>
24
+ </View>
25
+
26
+ <View style={{ alignItems: 'center' }}>
27
+ <Text
28
+ style={{ fontSize: 96, fontWeight: '800', lineHeight: 100, color: t['text-primary'] }}
29
+ >
30
+ 8
31
+ </Text>
32
+ <Text
33
+ style={{
34
+ fontSize: 12,
35
+ letterSpacing: 2,
36
+ textTransform: 'uppercase',
37
+ color: t['text-tertiary'],
38
+ }}
39
+ >
40
+ reps
41
+ </Text>
42
+ </View>
43
+
44
+ <View>
45
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 }}>
46
+ {[
47
+ ['0.42', 'm/s'],
48
+ ['8.5', 'RPE'],
49
+ ['−18%', 'vel loss'],
50
+ ].map(([v, l]) => (
51
+ <View key={l} style={{ alignItems: 'center' }}>
52
+ <Text style={{ fontSize: 20, fontWeight: '700', color: t['text-primary'] }}>{v}</Text>
53
+ <Text
54
+ style={{
55
+ fontSize: 9,
56
+ letterSpacing: 1,
57
+ textTransform: 'uppercase',
58
+ color: t['text-tertiary'],
59
+ }}
60
+ >
61
+ {l}
62
+ </Text>
63
+ </View>
64
+ ))}
65
+ </View>
66
+ <Text style={{ fontSize: 12, color: t['text-secondary'], textAlign: 'center' }}>
67
+ {caption}
68
+ </Text>
69
+ </View>
70
+ </View>
71
+ )
72
+ }
73
+
74
+ const PANEL = { width: 360, height: 620, borderRadius: 24, overflow: 'hidden' as const }
75
+
76
+ const meta: Meta<typeof LiveAuraFrame> = {
77
+ title: 'Workout/LiveAuraFrame',
78
+ component: LiveAuraFrame,
79
+ tags: ['autodocs'],
80
+ parameters: {
81
+ layout: 'fullscreen',
82
+ docs: {
83
+ description: {
84
+ component:
85
+ 'Full-surface color-flood frame tied to a coaching category. Wraps content on a charcoal ' +
86
+ 'base and layers a radial wash — amber at threshold (VL20), red at stop (VL28+, pulsing) — ' +
87
+ 'derived from the semantic warning / error tokens via `alpha()`. Children pass through. ' +
88
+ 'Rendered here at live-screen size so the flood reads (it is meant to tint a whole panel, ' +
89
+ 'not a card).',
90
+ },
91
+ },
92
+ },
93
+ argTypes: {
94
+ category: {
95
+ control: 'select',
96
+ options: ['productive', 'threshold', 'stop'],
97
+ description: 'Coaching-category flood level',
98
+ },
99
+ pulse: { control: 'boolean', description: 'Pulse the flood on stop' },
100
+ },
101
+ decorators: [
102
+ (Story) => (
103
+ <View
104
+ style={{
105
+ minHeight: 700,
106
+ backgroundColor: '#070707',
107
+ alignItems: 'center',
108
+ justifyContent: 'center',
109
+ padding: 32,
110
+ }}
111
+ >
112
+ <Story />
113
+ </View>
114
+ ),
115
+ ],
116
+ render: (args) => (
117
+ <LiveAuraFrame {...args} style={PANEL}>
118
+ <LiveHero
119
+ caption={
120
+ args.category === 'stop'
121
+ ? 'red flood — VL28+, stop the set'
122
+ : args.category === 'threshold'
123
+ ? 'amber flood — VL20 threshold reached'
124
+ : 'no flood — productive, quiet background'
125
+ }
126
+ />
127
+ </LiveAuraFrame>
128
+ ),
129
+ }
130
+
131
+ export default meta
132
+ type Story = StoryObj<typeof LiveAuraFrame>
133
+
134
+ export const Productive: Story = { args: { category: 'productive' } }
135
+ export const Threshold: Story = { args: { category: 'threshold' } }
136
+ export const Stop: Story = { args: { category: 'stop' } }
137
+
138
+ /** All three flood levels side by side at live-screen size. */
139
+ export const AllStates: Story = {
140
+ render: () => (
141
+ <View style={{ flexDirection: 'row', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
142
+ <LiveAuraFrame category="productive" style={PANEL}>
143
+ <LiveHero caption="no flood — productive" />
144
+ </LiveAuraFrame>
145
+ <LiveAuraFrame category="threshold" style={PANEL}>
146
+ <LiveHero caption="amber flood — VL20 threshold" />
147
+ </LiveAuraFrame>
148
+ <LiveAuraFrame category="stop" pulse={false} style={PANEL}>
149
+ <LiveHero caption="red flood — VL28+, stop" />
150
+ </LiveAuraFrame>
151
+ </View>
152
+ ),
153
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Text } from 'react-native'
5
+ import { LiveAuraFrame, liveAuraColor, type LiveAuraCategory } from './LiveAuraFrame'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+ import { alpha } from '../../../utils/colors'
8
+
9
+ const t = getSemanticColors('dark')
10
+
11
+ describe('LiveAuraFrame', () => {
12
+ it('renders the frame and passes children through', () => {
13
+ render(
14
+ <LiveAuraFrame category="productive">
15
+ <Text>Cable Row</Text>
16
+ </LiveAuraFrame>
17
+ )
18
+ expect(screen.getByTestId('live-aura-frame')).toBeInTheDocument()
19
+ expect(screen.getByText('Cable Row')).toBeInTheDocument()
20
+ })
21
+
22
+ it('sits on the charcoal base surface', () => {
23
+ render(<LiveAuraFrame category="productive" />)
24
+ expect(screen.getByTestId('live-aura-frame')).toHaveStyle({
25
+ backgroundColor: t['background-base'],
26
+ })
27
+ })
28
+
29
+ it('renders no flood overlay for the productive (quiet) category', () => {
30
+ render(<LiveAuraFrame category="productive" />)
31
+ expect(screen.queryByTestId('live-aura-flood')).not.toBeInTheDocument()
32
+ })
33
+
34
+ const floods: Array<{
35
+ category: LiveAuraCategory
36
+ token: 'status-warning' | 'status-error'
37
+ opacity: number
38
+ }> = [
39
+ { category: 'threshold', token: 'status-warning', opacity: 0.12 },
40
+ { category: 'stop', token: 'status-error', opacity: 0.18 },
41
+ ]
42
+
43
+ floods.forEach(({ category, token, opacity }) => {
44
+ it(`floods ${category} with a ${token}-tinted radial wash that fades to transparent`, () => {
45
+ render(<LiveAuraFrame category={category} />)
46
+ // Web (the target): a top-down radial gradient tinted with the token color,
47
+ // fading to transparent — matches R2's .aura-* CSS, NOT a flat surface fill.
48
+ const flood = screen.getByTestId('live-aura-flood') as HTMLElement
49
+ expect(flood.style.backgroundImage).toContain(alpha(t[token], opacity))
50
+ expect(flood.style.backgroundImage).toContain('transparent')
51
+ expect(flood.style.backgroundColor).toBe('')
52
+ })
53
+ })
54
+
55
+ it('exposes the resolved flood color via liveAuraColor', () => {
56
+ expect(liveAuraColor('productive')).toBeNull()
57
+ expect(liveAuraColor('threshold')).toBe(t['status-warning'])
58
+ expect(liveAuraColor('stop')).toBe(t['status-error'])
59
+ })
60
+
61
+ it('renders the flood for stop when pulse is disabled', () => {
62
+ render(<LiveAuraFrame category="stop" pulse={false} />)
63
+ expect(screen.getByTestId('live-aura-flood')).toBeInTheDocument()
64
+ })
65
+
66
+ it('has no a11y violations', async () => {
67
+ const { container } = render(
68
+ <LiveAuraFrame category="stop">
69
+ <Text>Set 4/5</Text>
70
+ </LiveAuraFrame>
71
+ )
72
+ expect(await axe(container)).toHaveNoViolations()
73
+ })
74
+ })