@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,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { SegmentedBar, type SegmentedBarSegment } from './SegmentedBar'
5
+
6
+ const threeSegments: SegmentedBarSegment[] = [
7
+ { color: '#D14343' },
8
+ { color: '#FF7900' },
9
+ { color: '#2ED573' },
10
+ ]
11
+
12
+ describe('SegmentedBar', () => {
13
+ it('renders one fill per segment', () => {
14
+ render(<SegmentedBar segments={threeSegments} />)
15
+ expect(screen.getAllByTestId('segmented-bar-segment')).toHaveLength(3)
16
+ })
17
+
18
+ it('honours the fill fraction as the fill width', () => {
19
+ render(<SegmentedBar segments={[{ color: '#2ED573', fill: 0.5 }]} />)
20
+ expect(screen.getByTestId('segmented-bar-segment')).toHaveStyle({ width: '50%' })
21
+ })
22
+
23
+ it('lets a composer override each segment testID', () => {
24
+ render(<SegmentedBar segments={threeSegments} segmentTestID={(_, i) => `seg-${i}`} />)
25
+ expect(screen.getByTestId('seg-0')).toBeInTheDocument()
26
+ expect(screen.getByTestId('seg-2')).toBeInTheDocument()
27
+ })
28
+
29
+ it('defaults to an 8px height and honours the height prop', () => {
30
+ const { rerender } = render(<SegmentedBar segments={threeSegments} testID="segmented-bar" />)
31
+ expect(screen.getByTestId('segmented-bar')).toHaveStyle({ height: '8px' })
32
+ rerender(<SegmentedBar segments={threeSegments} height={4} testID="segmented-bar" />)
33
+ expect(screen.getByTestId('segmented-bar')).toHaveStyle({ height: '4px' })
34
+ })
35
+
36
+ it('carves a leading gap before a segment as a fixed left margin on its slot', () => {
37
+ render(
38
+ <SegmentedBar
39
+ segments={[{ color: '#D14343' }, { color: '#FF7900', leadingGap: 3 }]}
40
+ gap={0}
41
+ segmentTestID={(_, i) => `seg-${i}`}
42
+ />
43
+ )
44
+ const slot = (fill: string) => screen.getByTestId(fill).parentElement as HTMLElement
45
+ expect(slot('seg-0')).not.toHaveStyle({ marginLeft: '3px' })
46
+ expect(slot('seg-1')).toHaveStyle({ marginLeft: '3px' })
47
+ })
48
+
49
+ it('applies a static fill opacity to a non-pulsing segment', () => {
50
+ render(<SegmentedBar segments={[{ color: '#0B3149', opacity: 0.36 }]} />)
51
+ expect(screen.getByTestId('segmented-bar-segment')).toHaveStyle({ opacity: 0.36 })
52
+ })
53
+
54
+ it('draws a marker line only when a marker is supplied', () => {
55
+ const { rerender } = render(<SegmentedBar segments={threeSegments} />)
56
+ expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
57
+ rerender(<SegmentedBar segments={threeSegments} marker={{ position: 0.5, color: '#01B5D1' }} />)
58
+ expect(screen.getByTestId('segmented-bar-marker')).toBeInTheDocument()
59
+ })
60
+
61
+ describe('accessibility', () => {
62
+ it('has no accessibility violations', async () => {
63
+ const { container } = render(
64
+ <SegmentedBar
65
+ segments={[
66
+ { color: '#2ED573' },
67
+ { color: '#FF7900', pulse: true },
68
+ { color: '#3A3A3A', fill: 0.4 },
69
+ ]}
70
+ marker={{ position: 0.75, color: '#01B5D1' }}
71
+ />
72
+ )
73
+ const results = await axe(container)
74
+ expect(results).toHaveNoViolations()
75
+ })
76
+ })
77
+ })
@@ -0,0 +1,172 @@
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, Animated, Easing, type ViewProps, type DimensionValue } from 'react-native'
4
+
5
+ /** ~1.9s full cycle: a pulsing segment eases up then back down (2 × half). */
6
+ const PULSE_HALF_MS = 950
7
+
8
+ /** Pulsing segments breathe their opacity between this floor and full. */
9
+ const PULSE_MIN_OPACITY = 0.45
10
+
11
+ /** Default between-segment gap — matches SetStrip's between-set gap so strips read the same. */
12
+ const SEGMENTED_BAR_GAP = 5
13
+
14
+ export interface SegmentedBarSegment {
15
+ /** Flex weight of this segment's slot. Default 1 (all segments equal width). */
16
+ weight?: number
17
+ /** Fraction 0..1 of the slot the coloured fill covers, left-aligned. Default 1 (full). */
18
+ fill?: number
19
+ /** Fill colour — a literal hex (RNW-safe), never a `var()` token ref. */
20
+ color: string
21
+ /** When true the fill animates on the shared active-pulse loop. */
22
+ pulse?: boolean
23
+ /**
24
+ * When pulsing, interpolate the fill's `backgroundColor` from→to over the loop
25
+ * (full opacity). Omit to fall back to the default opacity breathe.
26
+ */
27
+ pulseColor?: [from: string, to: string]
28
+ /**
29
+ * A fixed gap (px) carved in before this segment's slot — lets a composer split
30
+ * an otherwise-butted bar into sub-structure (drop notch, myo cluster) without a
31
+ * uniform {@link SegmentedBarProps.gap}. Default 0 (butted against the previous).
32
+ */
33
+ leadingGap?: number
34
+ /**
35
+ * Static fill opacity 0..1 for a non-pulsing segment (e.g. a fading "count
36
+ * unknown" trail). Ignored for `pulse` segments, which drive their own opacity.
37
+ */
38
+ opacity?: number
39
+ }
40
+
41
+ export interface SegmentedBarProps extends ViewProps {
42
+ /** The weighted segments, laid left-to-right. */
43
+ segments: SegmentedBarSegment[]
44
+ /** Track height in px. Default 8. */
45
+ height?: number
46
+ /** Gap between segment slots in px. Default 5 (SetStrip's between-set gap). */
47
+ gap?: number
48
+ /** Corner radius of each segment slot in px. Default 2. */
49
+ radius?: number
50
+ /** An absolutely-positioned vertical marker line at `position` (0..1 of total width). */
51
+ marker?: { position: number; color: string } | null
52
+ /**
53
+ * Resolve the testID of a segment's fill element. Lets a composing component
54
+ * (e.g. SetBar) preserve its own test hooks; defaults to `segmented-bar-segment`.
55
+ */
56
+ segmentTestID?: (segment: SegmentedBarSegment, index: number) => string | undefined
57
+ }
58
+
59
+ /** The shared active-pulse loop: a single 0→1→0 value driving every pulsing segment. */
60
+ function usePulse(active: boolean): Animated.Value {
61
+ const [value] = useState(() => new Animated.Value(0))
62
+
63
+ useEffect(() => {
64
+ if (!active) return
65
+ const loop = Animated.loop(
66
+ Animated.sequence([
67
+ Animated.timing(value, {
68
+ toValue: 1,
69
+ duration: PULSE_HALF_MS,
70
+ easing: Easing.inOut(Easing.ease),
71
+ useNativeDriver: false,
72
+ }),
73
+ Animated.timing(value, {
74
+ toValue: 0,
75
+ duration: PULSE_HALF_MS,
76
+ easing: Easing.inOut(Easing.ease),
77
+ useNativeDriver: false,
78
+ }),
79
+ ])
80
+ )
81
+ loop.start()
82
+ return () => loop.stop()
83
+ }, [active, value])
84
+
85
+ return value
86
+ }
87
+
88
+ /**
89
+ * A horizontal track split into weighted, individually-fillable segments — the
90
+ * presentational atom the per-set strips are built from. Each segment is a flex
91
+ * slot (flex = `weight`) holding a left-aligned fill (`fill` fraction, `color`);
92
+ * `pulse` segments breathe on a shared active-pulse loop, and `marker` drops a
93
+ * vertical line at a fraction of the total width. Colours are passed-in literal hex.
94
+ */
95
+ export function SegmentedBar({
96
+ segments,
97
+ height = 8,
98
+ gap = SEGMENTED_BAR_GAP,
99
+ radius = 2,
100
+ marker = null,
101
+ segmentTestID,
102
+ style,
103
+ ...props
104
+ }: SegmentedBarProps) {
105
+ const pulse = usePulse(segments.some((s) => s.pulse))
106
+
107
+ return (
108
+ <View style={[{ flexDirection: 'row', height, gap, position: 'relative' }, style]} {...props}>
109
+ {segments.map((seg, i) => {
110
+ const fillStyle = {
111
+ width: `${(seg.fill ?? 1) * 100}%` as DimensionValue,
112
+ height: '100%' as DimensionValue,
113
+ backgroundColor: seg.color,
114
+ opacity: seg.opacity,
115
+ }
116
+ const testID = segmentTestID?.(seg, i) ?? 'segmented-bar-segment'
117
+ return (
118
+ <View
119
+ key={i}
120
+ style={{
121
+ flex: seg.weight ?? 1,
122
+ minWidth: 0,
123
+ height: '100%',
124
+ marginLeft: seg.leadingGap,
125
+ borderRadius: radius,
126
+ overflow: 'hidden',
127
+ }}
128
+ >
129
+ {seg.pulse ? (
130
+ <Animated.View
131
+ style={{
132
+ ...fillStyle,
133
+ ...(seg.pulseColor
134
+ ? {
135
+ backgroundColor: pulse.interpolate({
136
+ inputRange: [0, 1],
137
+ outputRange: seg.pulseColor,
138
+ }),
139
+ }
140
+ : {
141
+ opacity: pulse.interpolate({
142
+ inputRange: [0, 1],
143
+ outputRange: [PULSE_MIN_OPACITY, 1],
144
+ }),
145
+ }),
146
+ }}
147
+ accessibilityElementsHidden
148
+ testID={testID}
149
+ />
150
+ ) : (
151
+ <View style={fillStyle} accessibilityElementsHidden testID={testID} />
152
+ )}
153
+ </View>
154
+ )
155
+ })}
156
+ {marker ? (
157
+ <View
158
+ style={{
159
+ position: 'absolute',
160
+ left: `${marker.position * 100}%` as DimensionValue,
161
+ top: 0,
162
+ bottom: 0,
163
+ width: 2,
164
+ backgroundColor: marker.color,
165
+ }}
166
+ accessibilityElementsHidden
167
+ testID="segmented-bar-marker"
168
+ />
169
+ ) : null}
170
+ </View>
171
+ )
172
+ }
@@ -0,0 +1,50 @@
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 { SegmentedProgressBar } from './SegmentedProgressBar'
5
+
6
+ const PLAN = [{ weight: 3 }, { weight: 3 }, { weight: 2 }, { weight: 2 }, { weight: 2 }]
7
+
8
+ const meta: Meta<typeof SegmentedProgressBar> = {
9
+ title: 'Workout/SegmentedProgressBar',
10
+ component: SegmentedProgressBar,
11
+ tags: ['autodocs'],
12
+ parameters: {
13
+ docs: {
14
+ description: {
15
+ component:
16
+ '**Atom.** Composes [SegmentedBar](?path=/docs/workout-segmentedbar--docs) + paceTone. Used-by ↑ SessionHeader.',
17
+ },
18
+ },
19
+ },
20
+ decorators: [
21
+ (Story) => (
22
+ <View style={{ width: 246, padding: 16, backgroundColor: '#242424' }}>
23
+ <Story />
24
+ </View>
25
+ ),
26
+ ],
27
+ }
28
+ export default meta
29
+
30
+ type Story = StoryObj<typeof SegmentedProgressBar>
31
+
32
+ /** Fill (sets done) trails the time marker → amber pace. */
33
+ export const Behind: Story = {
34
+ args: { segments: PLAN, value: 7.2, target: 0.7 },
35
+ }
36
+
37
+ /** Fill is at or past the time marker → green pace. */
38
+ export const Ahead: Story = {
39
+ args: { segments: PLAN, value: 9.5, target: 0.7 },
40
+ }
41
+
42
+ /** No target → no marker, plain steel progress. */
43
+ export const NoTarget: Story = {
44
+ args: { segments: PLAN, value: 7 },
45
+ }
46
+
47
+ /** Nothing performed yet — the empty plan shape. */
48
+ export const Empty: Story = {
49
+ args: { segments: PLAN, value: 0 },
50
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { SegmentedProgressBar, type SegmentedProgressBarSegment } from './SegmentedProgressBar'
5
+ import { paceTone, paceToneColor } from './paceTone'
6
+
7
+ const PLAN: SegmentedProgressBarSegment[] = [
8
+ { weight: 3 },
9
+ { weight: 3 },
10
+ { weight: 2 },
11
+ { weight: 2 },
12
+ { weight: 2 },
13
+ ] // total = 12
14
+
15
+ describe('SegmentedProgressBar', () => {
16
+ it('renders one segment fill per weighted slot', () => {
17
+ render(<SegmentedProgressBar segments={PLAN} value={0} />)
18
+ expect(screen.getAllByTestId('segmented-bar-segment')).toHaveLength(5)
19
+ })
20
+
21
+ it('fills each chunk by its clamped share of the cumulative value', () => {
22
+ // value 7 over weights [3,3,2,2,2]: 100%,100%,50%,0%,0%
23
+ render(<SegmentedProgressBar segments={PLAN} value={7} target={0.7} />)
24
+ const fills = screen.getAllByTestId('segmented-bar-segment')
25
+ expect(fills[0]).toHaveStyle({ width: '100%' })
26
+ expect(fills[1]).toHaveStyle({ width: '100%' })
27
+ expect(fills[2]).toHaveStyle({ width: '50%' })
28
+ expect(fills[3]).toHaveStyle({ width: '0%' })
29
+ expect(fills[4]).toHaveStyle({ width: '0%' })
30
+ })
31
+
32
+ it('uses the behind (warning) pace colour when fill trails the target', () => {
33
+ render(<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} />)
34
+ const behind = paceToneColor(paceTone(7.2 / 12, 0.7))
35
+ expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
36
+ backgroundColor: behind,
37
+ })
38
+ })
39
+
40
+ it('uses the ahead (success) pace colour when fill is at or past the target', () => {
41
+ render(<SegmentedProgressBar segments={PLAN} value={9.5} target={0.7} />)
42
+ const ahead = paceToneColor(paceTone(9.5 / 12, 0.7))
43
+ expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
44
+ backgroundColor: ahead,
45
+ })
46
+ })
47
+
48
+ it('is neutral (steel) with no marker when no target is given', () => {
49
+ render(<SegmentedProgressBar segments={PLAN} value={7} />)
50
+ expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
51
+ const neutral = paceToneColor('neutral')
52
+ expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
53
+ backgroundColor: neutral,
54
+ })
55
+ })
56
+
57
+ it('drops the overlay marker at the target position when target is given', () => {
58
+ render(<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} />)
59
+ const marker = screen.getByTestId('segmented-bar-marker')
60
+ expect(marker).toBeInTheDocument()
61
+ expect(marker).toHaveStyle({ left: '70%' })
62
+ })
63
+
64
+ it('lets an explicit colour override the pace tone', () => {
65
+ render(<SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} color="#123456" />)
66
+ expect(screen.getAllByTestId('segmented-bar-segment')[0]).toHaveStyle({
67
+ backgroundColor: '#123456',
68
+ })
69
+ })
70
+
71
+ describe('accessibility', () => {
72
+ it('has no accessibility violations', async () => {
73
+ const { container } = render(
74
+ <SegmentedProgressBar segments={PLAN} value={7.2} target={0.7} />
75
+ )
76
+ const results = await axe(container)
77
+ expect(results).toHaveNoViolations()
78
+ })
79
+ })
80
+ })
@@ -0,0 +1,67 @@
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 { primitiveRamps } from '../../../theme/tokens/primitives'
4
+ import { SegmentedBar } from './SegmentedBar'
5
+ import { paceTone, paceToneColor } from './paceTone'
6
+
7
+ /** Default overlay-marker colour: status-live-muted — the SideNav "Live" cue / active-timer green. */
8
+ const DEFAULT_MARKER_COLOR = primitiveRamps.green[500]
9
+
10
+ export interface SegmentedProgressBarSegment {
11
+ /** Weight of this segment's slot — e.g. an exercise's planned set count. */
12
+ weight: number
13
+ }
14
+
15
+ export interface SegmentedProgressBarProps extends ViewProps {
16
+ /** The weighted segments, laid left-to-right (e.g. one per exercise). */
17
+ segments: SegmentedProgressBarSegment[]
18
+ /** Cumulative progress in the SAME units as the summed weights (e.g. sets done). May be fractional. */
19
+ value: number
20
+ /** 0..1 position for the overlay pace marker (e.g. elapsed / budget). Omit → no marker, neutral tone. */
21
+ target?: number
22
+ /** Overlay-marker colour. Default the live-green (status-live-muted). */
23
+ markerColor?: string
24
+ /** Override the single pace fill colour. Default = paceToneColor(paceTone(value / total, target)). */
25
+ color?: string
26
+ /** Track height in px. Passed through to SegmentedBar. */
27
+ height?: number
28
+ /** Gap between segment slots in px. Passed through to SegmentedBar. */
29
+ gap?: number
30
+ }
31
+
32
+ /**
33
+ * A progress bar segmented by weighted slots (one chunk per exercise, width ∝ its
34
+ * set count): `value` fills the chunks left-to-right, and a single pace colour spans
35
+ * them all — green when at/ahead of the `target` pace, amber when behind, steel when
36
+ * there is no target. An optional overlay marker pins the target position. Composes
37
+ * {@link SegmentedBar} + {@link paceTone}.
38
+ */
39
+ export function SegmentedProgressBar({
40
+ segments,
41
+ value,
42
+ target,
43
+ markerColor = DEFAULT_MARKER_COLOR,
44
+ color,
45
+ height,
46
+ gap,
47
+ ...props
48
+ }: SegmentedProgressBarProps) {
49
+ const total = segments.reduce((sum, s) => sum + s.weight, 0)
50
+ const fillColor = color ?? paceToneColor(paceTone(total > 0 ? value / total : 0, target))
51
+
52
+ const barSegments = segments.map((seg, i) => {
53
+ const cumulativeBefore = segments.slice(0, i).reduce((sum, s) => sum + s.weight, 0)
54
+ const fill = Math.max(0, Math.min(1, (value - cumulativeBefore) / seg.weight))
55
+ return { weight: seg.weight, fill, color: fillColor }
56
+ })
57
+
58
+ return (
59
+ <SegmentedBar
60
+ segments={barSegments}
61
+ marker={target != null ? { position: target, color: markerColor } : null}
62
+ height={height}
63
+ gap={gap}
64
+ {...props}
65
+ />
66
+ )
67
+ }
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { primitiveRamps } from '../../../theme/tokens/primitives'
4
+ import { SessionHeader, type SessionHeaderPlanEntry } from './SessionHeader'
5
+
6
+ /**
7
+ * `SessionHeader` — the session-rail heading: the session title over a stat row and a
8
+ * chunked pace bar. Live, it carries Volume/Load/Fatigue tiles, a per-exercise progress
9
+ * bar (fill ∝ sets done, coloured against the elapsed-vs-budget pace with a live marker),
10
+ * a mono `n/total sets` label and a ⏱ elapsed readout. Upcoming, the tiles become
11
+ * Date/Time/Until, the bar is the empty plan shape, and the readout shows the planned
12
+ * duration. Rendered on the raised charcoal plane.
13
+ */
14
+ const meta: Meta<typeof SessionHeader> = {
15
+ title: 'Shell/SessionRail/SessionHeader',
16
+ component: SessionHeader,
17
+ tags: ['autodocs'],
18
+ parameters: {
19
+ docs: {
20
+ description: {
21
+ component:
22
+ '**Molecule** (shell S3). The session-rail heading — title, stat tiles, chunked ' +
23
+ 'pace bar, sets label + ⏱ readout, on the raised charcoal plane. Composes ' +
24
+ '[MetricTiles](?path=/docs/workout-metrictiles--docs) / ' +
25
+ '[ScheduleTiles](?path=/docs/workout-scheduletiles--docs) + ' +
26
+ '[SegmentedProgressBar](?path=/docs/workout-segmentedprogressbar--docs) + ' +
27
+ '[TimerReadout](?path=/docs/components-atoms-timerreadout--docs). ' +
28
+ 'Used-by ↑ [SessionRail](?path=/docs/shell-sessionrail--docs).',
29
+ },
30
+ },
31
+ },
32
+ decorators: [
33
+ (Story) => (
34
+ <View style={{ width: 246 }}>
35
+ <Story />
36
+ </View>
37
+ ),
38
+ ],
39
+ }
40
+
41
+ export default meta
42
+ type Story = StoryObj<typeof SessionHeader>
43
+
44
+ const AMBER = primitiveRamps.amber[300]
45
+
46
+ const PLAN: SessionHeaderPlanEntry[] = [
47
+ { name: 'Seated Row', sets: 3 },
48
+ { name: 'Incline DB', sets: 3 },
49
+ { name: 'Chest Press', sets: 2 },
50
+ { name: 'Calf Raise', sets: 2 },
51
+ { name: 'Face Pull', sets: 2 },
52
+ ]
53
+
54
+ const LIVE_METRICS = [
55
+ { label: 'Volume', value: '76%' },
56
+ { label: 'Load', value: '7.3k' },
57
+ { label: 'Fatigue', value: 'MOD', valueColor: AMBER },
58
+ ]
59
+
60
+ // 42:18 elapsed against a 60:00 budget → ~70% of the way through the time budget while
61
+ // only 7.2/12 sets are done, so the fill trails the marker (amber, behind pace).
62
+ const ELAPSED_MS = (42 * 60 + 18) * 1000
63
+ const BUDGET_MS = 60 * 60 * 1000
64
+
65
+ export const LiveBehind: Story = {
66
+ args: {
67
+ title: 'Pull A · Intensification',
68
+ plan: PLAN,
69
+ setsDone: 7.2,
70
+ elapsedMs: ELAPSED_MS,
71
+ budgetMs: BUDGET_MS,
72
+ metrics: LIVE_METRICS,
73
+ },
74
+ parameters: {
75
+ docs: {
76
+ description: {
77
+ story:
78
+ 'Live, behind pace — sets fill (7.2/12) trails the elapsed-time marker, so the ' +
79
+ 'bar and sets label read amber. ⏱ elapsed goes live-green with the ` / 60:00` budget.',
80
+ },
81
+ },
82
+ },
83
+ }
84
+
85
+ export const LiveNoTime: Story = {
86
+ args: {
87
+ title: 'Pull A · Intensification',
88
+ plan: PLAN,
89
+ setsDone: 7.2,
90
+ elapsedMs: ELAPSED_MS,
91
+ metrics: LIVE_METRICS,
92
+ },
93
+ parameters: {
94
+ docs: {
95
+ description: {
96
+ story:
97
+ 'Live, no time target — no marker and no ` / budget`, just the ⏱ elapsed. The bar ' +
98
+ 'is plain chunked progress (steel), and the sets label follows.',
99
+ },
100
+ },
101
+ },
102
+ }
103
+
104
+ export const Upcoming: Story = {
105
+ args: {
106
+ title: 'Lower B · Volume',
107
+ plan: PLAN,
108
+ budgetMs: 52 * 60 * 1000,
109
+ next: Date.now() + 2 * 24 * 60 * 60 * 1000,
110
+ },
111
+ parameters: {
112
+ docs: {
113
+ description: {
114
+ story:
115
+ 'Upcoming session — the tiles become Date / Time / Until (Until accents only when ' +
116
+ '< 1 day away), the bar is the empty plan shape, and the ⏱ readout shows the planned ' +
117
+ 'duration. `title` is the next session’s name.',
118
+ },
119
+ },
120
+ },
121
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { SessionHeader, type SessionHeaderPlanEntry } from './SessionHeader'
5
+
6
+ const PLAN: SessionHeaderPlanEntry[] = [
7
+ { name: 'Seated Row', sets: 3 },
8
+ { name: 'Incline DB', sets: 3 },
9
+ { name: 'Chest Press', sets: 2 },
10
+ { name: 'Calf Raise', sets: 2 },
11
+ { name: 'Face Pull', sets: 2 },
12
+ ] // total = 12
13
+
14
+ const liveProps = {
15
+ title: 'Pull A · Intensification',
16
+ plan: PLAN,
17
+ setsDone: 7.2,
18
+ elapsedMs: (42 * 60 + 18) * 1000,
19
+ budgetMs: 60 * 60 * 1000,
20
+ metrics: [
21
+ { label: 'Volume', value: '76%' },
22
+ { label: 'Load', value: '7.3k' },
23
+ { label: 'Fatigue', value: 'MOD' },
24
+ ],
25
+ }
26
+
27
+ describe('SessionHeader', () => {
28
+ it('renders the session title', () => {
29
+ render(<SessionHeader {...liveProps} />)
30
+ expect(screen.getByTestId('session-rail-title')).toHaveTextContent('Pull A · Intensification')
31
+ })
32
+
33
+ describe('live · behind pace', () => {
34
+ it('shows the floored sets-done over the total', () => {
35
+ render(<SessionHeader {...liveProps} />)
36
+ expect(screen.getByTestId('session-rail-sets')).toHaveTextContent('7/12 sets')
37
+ })
38
+
39
+ it('renders the live metric tiles', () => {
40
+ render(<SessionHeader {...liveProps} />)
41
+ expect(screen.getByText('Volume')).toBeInTheDocument()
42
+ expect(screen.getByText('Fatigue')).toBeInTheDocument()
43
+ })
44
+
45
+ it('drops the pace marker when a budget and elapsed are given', () => {
46
+ render(<SessionHeader {...liveProps} />)
47
+ expect(screen.getByTestId('segmented-bar-marker')).toBeInTheDocument()
48
+ })
49
+
50
+ it('appends the budget to the timer readout', () => {
51
+ render(<SessionHeader {...liveProps} />)
52
+ expect(screen.getByTestId('timer-readout-current')).toHaveTextContent('42:18')
53
+ expect(screen.getByTestId('timer-readout-total')).toHaveTextContent('60:00')
54
+ })
55
+ })
56
+
57
+ describe('live · no time target', () => {
58
+ const noTimeProps = { ...liveProps, budgetMs: undefined }
59
+
60
+ it('omits the pace marker', () => {
61
+ render(<SessionHeader {...noTimeProps} />)
62
+ expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
63
+ })
64
+
65
+ it('shows the elapsed readout with no budget suffix', () => {
66
+ render(<SessionHeader {...noTimeProps} />)
67
+ expect(screen.getByTestId('timer-readout-current')).toHaveTextContent('42:18')
68
+ expect(screen.queryByTestId('timer-readout-total')).not.toBeInTheDocument()
69
+ })
70
+ })
71
+
72
+ describe('upcoming', () => {
73
+ const upcomingProps = {
74
+ title: 'Lower B · Volume',
75
+ plan: PLAN,
76
+ budgetMs: 52 * 60 * 1000,
77
+ next: new Date('2026-07-11T18:30:00'),
78
+ }
79
+
80
+ it('renders the next session name as the title', () => {
81
+ render(<SessionHeader {...upcomingProps} />)
82
+ expect(screen.getByTestId('session-rail-title')).toHaveTextContent('Lower B · Volume')
83
+ })
84
+
85
+ it('shows the schedule tiles instead of metric tiles', () => {
86
+ render(<SessionHeader {...upcomingProps} />)
87
+ expect(screen.getByText('Date')).toBeInTheDocument()
88
+ expect(screen.getByText('Time')).toBeInTheDocument()
89
+ expect(screen.getByText('Until')).toBeInTheDocument()
90
+ })
91
+
92
+ it('shows only the total sets and no marker', () => {
93
+ render(<SessionHeader {...upcomingProps} />)
94
+ expect(screen.getByTestId('session-rail-sets')).toHaveTextContent('12 sets')
95
+ expect(screen.queryByTestId('segmented-bar-marker')).not.toBeInTheDocument()
96
+ })
97
+
98
+ it('reads the planned duration statically (not live-green)', () => {
99
+ render(<SessionHeader {...upcomingProps} />)
100
+ expect(screen.getByTestId('timer-readout-current')).toHaveTextContent('52:00')
101
+ })
102
+ })
103
+
104
+ describe('accessibility', () => {
105
+ it('has no violations when live', async () => {
106
+ const { container } = render(<SessionHeader {...liveProps} />)
107
+ expect(await axe(container)).toHaveNoViolations()
108
+ })
109
+
110
+ it('has no violations when upcoming', async () => {
111
+ const { container } = render(
112
+ <SessionHeader
113
+ title="Lower B · Volume"
114
+ plan={PLAN}
115
+ budgetMs={52 * 60 * 1000}
116
+ next={new Date('2026-07-11T18:30:00')}
117
+ />
118
+ )
119
+ expect(await axe(container)).toHaveNoViolations()
120
+ })
121
+ })
122
+ })