@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
@@ -1,8 +1,18 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useEffect, useState } from 'react'
3
- import { View, Text, Pressable, Animated, Easing, type ViewProps, type ViewStyle } from 'react-native'
3
+ import {
4
+ View,
5
+ Text,
6
+ Pressable,
7
+ Animated,
8
+ Easing,
9
+ type ViewProps,
10
+ type ViewStyle,
11
+ } from 'react-native'
4
12
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
13
+ import { primitiveColors, primitiveRamps, sequentialEffort } from '../../../theme/tokens/primitives'
5
14
  import { formatVelocity } from '../../../utils/workout-format'
15
+ import { SET_STRIP_VARIABLE_COLOR } from './SetStrip'
6
16
 
7
17
  /**
8
18
  * Structural velocity-zone band accepted from an upstream analytics source
@@ -32,8 +42,20 @@ export interface VelocityStripProps extends ViewProps {
32
42
  * Drives both bar height and — in the default path — zone color. Callers must
33
43
  * feed mean (not peak) concentric velocity so bar color, height, and the
34
44
  * summary label all describe one metric.
45
+ *
46
+ * Optional: provide exactly one of `velocities` or {@link VelocityStripProps.set}.
47
+ * When neither is given the strip renders nothing.
35
48
  */
36
- velocities: number[]
49
+ velocities?: number[]
50
+ /**
51
+ * Optional structured strength-training set descriptor (see {@link VelocitySet}).
52
+ * When present it supersedes `velocities`: the done-velocity array is derived
53
+ * from the set (so mean / loss / zone summaries still work) and the strip is
54
+ * drawn as a typed slot list — grey todo, cyan variable / continue windows and
55
+ * the wide-gap chunking that distinguishes drop / myo / cluster sets. Provide
56
+ * exactly one of `velocities` or `set`.
57
+ */
58
+ set?: VelocitySet
37
59
  /**
38
60
  * Optional velocity-zone bands (shape-compatible with WA's
39
61
  * `VelocityZones.bands`). When provided, bar color and the summary zone label
@@ -46,13 +68,36 @@ export interface VelocityStripProps extends ViewProps {
46
68
  * Live mode: index of the most-recently-completed rep. That bar animates in
47
69
  * with a "pop"; if it is also the current set peak (a new best), it "bounces"
48
70
  * instead. Only the latest bar animates. Honors `prefers-reduced-motion`.
49
- * Full variant only.
71
+ * Framed `expanded` chart only (interactive tap-to-expand use).
50
72
  */
51
73
  liveRepIndex?: number
74
+ /**
75
+ * `expanded` variant: whether the chart is OPEN. Default true (the variant shows
76
+ * its chart). Toggle it (with {@link onToggle}) for the interactive tap-to-expand
77
+ * collapse↔chart animation; a static open chart (no `onToggle`) does not animate.
78
+ */
52
79
  expanded?: boolean
53
80
  onToggle?: () => void
54
81
  onRepPress?: (index: number, velocity: number) => void
55
- variant?: 'full' | 'mini'
82
+ /**
83
+ * `mini` — a flat 3px static strip (set-type aware). `expanded` — the velocity-
84
+ * HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
85
+ * {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface,
86
+ * padding, per-bar m/s labels, mean/loss info row, interactive collapse); with both
87
+ * off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}.
88
+ */
89
+ variant?: 'mini' | 'expanded'
90
+ /** `expanded` plot height in px (bars scale to this). Default 60. */
91
+ height?: number
92
+ /**
93
+ * `expanded` bar scaling. `peak` (default) scales to the set's own max (+15%
94
+ * headroom). `fixed` scales to a fixed velocity ceiling so bar heights read the
95
+ * same absolute velocity across sets (the spotlight).
96
+ */
97
+ scale?: 'peak' | 'fixed'
98
+ /** `expanded` framed chart: per-bar m/s labels. Default true. */
99
+ showNumbers?: boolean
100
+ /** `expanded` framed chart: the mean/loss info row. Default true. */
56
101
  showInfo?: boolean
57
102
  className?: string
58
103
  }
@@ -62,26 +107,25 @@ export interface VelocityStripProps extends ViewProps {
62
107
  const VEL_COLORS = WORKOUT_TOKENS.scale
63
108
 
64
109
  /**
65
- * Map a velocity-zone id (WA's 5-band taxonomy) onto the canonical 4-color
66
- * scale. The scale has 4 hues but the taxonomy has 5 bands, so the two slowest
67
- * bands `maximalStrength` and `grinding` intentionally share `red`: both
68
- * read as "heavy / effortful" and there is no distinct 5th data-viz hue. The
69
- * top three bands align 1:1 with the legacy default scale so an exercise moving
70
- * from default to profile-derived zones never shifts its fast-end colors.
71
- *
72
- * Legacy default parity: speed=green, power=yellow, strengthSpeed=orange, and
73
- * the old sub-0.5 "Strength" band (now split into maximalStrength + grinding)
74
- * stays red.
110
+ * Map a velocity-zone id (WA's 5-band taxonomy) directly onto the 6-stop
111
+ * `sequentialEffort` ramp. The default strip uses the 4-color {@link VEL_COLORS}
112
+ * scale (a subsample of the same ramp), but the 5-band taxonomy has one more
113
+ * level than that scale carries, so it samples 5 stops: the top four align with
114
+ * the default scale (speed/power/strengthSpeed/maximalStrength = green/gold/
115
+ * orange/red) and `grinding` takes the ramp's darker red. `maximalStrength` and
116
+ * `grinding` therefore stay DISTINCT — they no longer collapse onto one red for
117
+ * lack of a 5th hue. An exercise moving from default to profile-derived zones
118
+ * never shifts its fast-end colors.
75
119
  *
76
120
  * Unknown ids fall back to `green` (matching the historical default-path
77
121
  * fallback), so a forward-compatible band id never renders an empty bar.
78
122
  */
79
- const zoneIdToScaleToken: Record<string, keyof typeof VEL_COLORS> = {
80
- speed: 'green',
81
- power: 'yellow',
82
- strengthSpeed: 'orange',
83
- maximalStrength: 'red',
84
- grinding: 'red',
123
+ const bandIdToEffortColor: Record<string, string> = {
124
+ speed: sequentialEffort[0], // green-300
125
+ power: sequentialEffort[2], // amber-300 (gold)
126
+ strengthSpeed: sequentialEffort[3], // orange-400
127
+ maximalStrength: sequentialEffort[4], // red-600
128
+ grinding: sequentialEffort[5], // red-700 — the distinct 5th band
85
129
  }
86
130
 
87
131
  // --- Default (no-zones) scale ------------------------------------------------
@@ -131,7 +175,7 @@ export function calculateMeanVelocity(velocities: number[]): number {
131
175
  /** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
132
176
  function classifyBand(
133
177
  velocity: number,
134
- bands: readonly VelocityZoneBandProp[],
178
+ bands: readonly VelocityZoneBandProp[]
135
179
  ): VelocityZoneBandProp | undefined {
136
180
  for (const band of bands) {
137
181
  if (band.max === null || velocity < band.max) return band
@@ -140,8 +184,7 @@ function classifyBand(
140
184
  }
141
185
 
142
186
  function bandColor(band: VelocityZoneBandProp): string {
143
- const token = zoneIdToScaleToken[band.id]
144
- return token ? VEL_COLORS[token] : VEL_COLORS.green
187
+ return bandIdToEffortColor[band.id] ?? VEL_COLORS.green
145
188
  }
146
189
 
147
190
  function getLossStyle(loss: number): Record<string, string> | null {
@@ -150,6 +193,197 @@ function getLossStyle(loss: number): Record<string, string> | null {
150
193
  return null
151
194
  }
152
195
 
196
+ // --- Set-type slot model -----------------------------------------------------
197
+ // The strength-training set vocabulary (operator-approved in the Set Modalities
198
+ // exploration): one strip encoding for every per-rep set type. A set resolves to
199
+ // an ordered list of slots; the slot `kind` picks the color and `leadingGap`
200
+ // encodes the chunk pattern (rep gap vs the wide notch that splits drop sub-loads
201
+ // / myo clusters / cluster intra-rests). Set-LEVEL types (pyramid / superset /
202
+ // tempo) belong to the heading, not the strip.
203
+
204
+ /**
205
+ * A structured strength-training set. Supply as {@link VelocityStripProps.set} to
206
+ * render its set-type encoding; the done-velocity array (used for the mean / loss
207
+ * / zone summary) is derived from the type's velocity fields.
208
+ */
209
+ export type VelocitySet =
210
+ /** Fixed reps at a fixed load: done reps colored + a grey todo remainder to `planned`. */
211
+ | { type: 'straight'; velocities: number[]; planned?: number }
212
+ /** Bounded reps: committed slots `0..floor`, then a cyan variable window `floor..max`. */
213
+ | { type: 'range'; velocities: number[]; floor: number; max: number }
214
+ /** As-many-reps-as-possible: done reps + a trailing cyan "continue" slot that never closes. */
215
+ | { type: 'amrap'; velocities: number[]; target?: number }
216
+ /** Load drops with no rest: each sub-load's reps, split by a WIDE notch gap. */
217
+ | { type: 'drop'; subloads: number[][] }
218
+ /** Rest-pause: an activation chunk + mini-clusters split by WIDE gaps; `open` adds a cyan continue. */
219
+ | { type: 'myo'; activation: number[]; clusters: number[][]; open?: boolean }
220
+ /** Fixed count broken by intra-set rests: reps grouped by `groupSize` with WIDE intra-rest gaps. */
221
+ | { type: 'cluster'; velocities: number[]; groupSize: number; planned?: number }
222
+
223
+ /** One drawn cell of the strip. `kind` picks the color; `leadingGap` the spacing before it. */
224
+ interface VelocitySlot {
225
+ kind: 'rep' | 'todo' | 'variable' | 'continue'
226
+ velocity?: number
227
+ leadingGap: number
228
+ }
229
+
230
+ /** Butted reps carry this gap; chunk boundaries carry {@link WIDE_GAP}. */
231
+ const REP_GAP = 2
232
+ /**
233
+ * The between-group notch (drop sub-loads / myo clusters / cluster intra-rests): a
234
+ * fixed 8px total gap that reads as a group break without dominating a narrow strip.
235
+ * 4× the rep gap — enough to separate groups at session-rail scale AND wall scale
236
+ * without the notch ballooning at large widths. In the mini variant the container's
237
+ * 2px gap covers part of it, so per-slot margin adds the remaining 6px (2 + 6 = 8).
238
+ */
239
+ const WIDE_GAP = 8
240
+
241
+ /** Grey fill for planned-but-unperformed reps (charcoal placeholder, literal hex). */
242
+ const TODO_COLOR = primitiveColors.charcoal[300]
243
+ /** The thin cyan-800 outline on the open-ended "continue" slot — reads as "keep going". */
244
+ const CONTINUE_OUTLINE = primitiveRamps.cyan[800]
245
+
246
+ /** The done-velocity array a set contributes to the mean / loss / zone summary. */
247
+ function deriveDoneVelocities(set: VelocitySet): number[] {
248
+ switch (set.type) {
249
+ case 'drop':
250
+ return set.subloads.flat()
251
+ case 'myo':
252
+ return [...set.activation, ...set.clusters.flat()]
253
+ default:
254
+ return set.velocities
255
+ }
256
+ }
257
+
258
+ /** Flatten velocity chunks into rep slots, carving a WIDE gap before each chunk but the first. */
259
+ function chunkedRepSlots(chunks: number[][]): VelocitySlot[] {
260
+ const slots: VelocitySlot[] = []
261
+ chunks.forEach((chunk) => {
262
+ chunk.forEach((velocity, ri) => {
263
+ const first = slots.length === 0
264
+ slots.push({ kind: 'rep', velocity, leadingGap: first ? 0 : ri === 0 ? WIDE_GAP : REP_GAP })
265
+ })
266
+ })
267
+ return slots
268
+ }
269
+
270
+ /** Resolve a set to its ordered slot list — the exact vocabulary of the Set Modalities sheet. */
271
+ function buildSlots(set: VelocitySet): VelocitySlot[] {
272
+ switch (set.type) {
273
+ case 'straight': {
274
+ const total = Math.max(set.velocities.length, set.planned ?? set.velocities.length)
275
+ return Array.from({ length: total }, (_, i) => {
276
+ const done = i < set.velocities.length
277
+ return {
278
+ kind: done ? 'rep' : 'todo',
279
+ velocity: done ? set.velocities[i] : undefined,
280
+ leadingGap: i === 0 ? 0 : REP_GAP,
281
+ }
282
+ })
283
+ }
284
+ case 'range': {
285
+ const total = Math.max(set.max, set.velocities.length)
286
+ return Array.from({ length: total }, (_, i) => {
287
+ const kind: VelocitySlot['kind'] =
288
+ i < set.velocities.length ? 'rep' : i < set.floor ? 'todo' : 'variable'
289
+ return {
290
+ kind,
291
+ velocity: kind === 'rep' ? set.velocities[i] : undefined,
292
+ leadingGap: i === 0 ? 0 : REP_GAP,
293
+ }
294
+ })
295
+ }
296
+ case 'amrap': {
297
+ const reps = set.velocities.map<VelocitySlot>((velocity, i) => ({
298
+ kind: 'rep',
299
+ velocity,
300
+ leadingGap: i === 0 ? 0 : REP_GAP,
301
+ }))
302
+ reps.push({ kind: 'continue', leadingGap: reps.length === 0 ? 0 : REP_GAP })
303
+ return reps
304
+ }
305
+ case 'drop':
306
+ return chunkedRepSlots(set.subloads)
307
+ case 'myo': {
308
+ const slots = chunkedRepSlots([set.activation, ...set.clusters])
309
+ if (set.open) slots.push({ kind: 'continue', leadingGap: slots.length === 0 ? 0 : REP_GAP })
310
+ return slots
311
+ }
312
+ case 'cluster': {
313
+ const total = Math.max(set.velocities.length, set.planned ?? set.velocities.length)
314
+ return Array.from({ length: total }, (_, i) => {
315
+ const done = i < set.velocities.length
316
+ const boundary = i > 0 && i % set.groupSize === 0
317
+ return {
318
+ kind: done ? 'rep' : 'todo',
319
+ velocity: done ? set.velocities[i] : undefined,
320
+ leadingGap: i === 0 ? 0 : boundary ? WIDE_GAP : REP_GAP,
321
+ }
322
+ })
323
+ }
324
+ }
325
+ }
326
+
327
+ /** A concise, set-type-aware summary for the strip's accessibility label. */
328
+ function setAccessibilityLabel(set: VelocitySet, repCount: number): string {
329
+ switch (set.type) {
330
+ case 'straight':
331
+ return `Velocity strip, straight set, ${repCount} reps`
332
+ case 'range':
333
+ return `Velocity strip, rep-range set, floor ${set.floor} to ${set.max}, ${repCount} reps done`
334
+ case 'amrap':
335
+ return `Velocity strip, AMRAP set, ${repCount} reps and counting`
336
+ case 'drop':
337
+ return `Velocity strip, drop set, ${set.subloads.length} loads, ${repCount} reps`
338
+ case 'myo':
339
+ return `Velocity strip, myo-reps set, ${set.clusters.length} clusters${set.open ? ', open' : ''}, ${repCount} reps`
340
+ case 'cluster':
341
+ return `Velocity strip, cluster set, groups of ${set.groupSize}, ${repCount} reps`
342
+ }
343
+ }
344
+
345
+ /** Per-slot accessibility label for the expanded set-type bars. */
346
+ function slotAccessibilityLabel(slot: VelocitySlot, index: number): string {
347
+ switch (slot.kind) {
348
+ case 'rep':
349
+ return `Rep ${index + 1}: ${formatVelocity(slot.velocity ?? 0)} meters per second`
350
+ case 'todo':
351
+ return `Rep ${index + 1}: planned`
352
+ case 'variable':
353
+ return `Rep ${index + 1}: variable`
354
+ case 'continue':
355
+ return 'Keep going'
356
+ }
357
+ }
358
+
359
+ /** Straight-set expanded stub height for a planned (todo) rep, as a % of the plot. */
360
+ const EXPANDED_TODO_STUB_PCT = 16
361
+ /** Expanded height for advanced set-types (drop / myo / cluster / range / amrap): a short mini-style bar. */
362
+ const EXPANDED_ENCODED_PCT = 45
363
+
364
+ /** Default framed `expanded` chart height (px). */
365
+ const EXPANDED_HEIGHT = 60
366
+ /**
367
+ * The `scale="fixed"` velocity ceiling (m/s). `peak` scales to the set's own max
368
+ * (+15% headroom); `fixed` uses this constant so a bar's height reads the same
369
+ * absolute velocity across sets (the spotlight).
370
+ */
371
+ const FIXED_MAX_VELOCITY = 1.15
372
+ /** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
373
+ const BARE_STUB_HEIGHT = 3
374
+
375
+ /** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
376
+ function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
377
+ return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * 1.15
378
+ }
379
+
380
+ /** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
381
+ function bareSlotHeight(slot: VelocitySlot, height: number, denom: number): number {
382
+ if (slot.kind !== 'rep' || denom <= 0) return BARE_STUB_HEIGHT
383
+ const ratio = Math.min(1, (slot.velocity ?? 0) / denom)
384
+ return Math.max(BARE_STUB_HEIGHT, ratio * height)
385
+ }
386
+
153
387
  function getReducedMotionPreference(): boolean {
154
388
  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
155
389
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -175,38 +409,64 @@ const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
175
409
 
176
410
  export function VelocityStrip({
177
411
  velocities,
412
+ set,
178
413
  zones,
179
414
  liveRepIndex,
180
- expanded = false,
415
+ expanded = true,
181
416
  onToggle,
182
417
  onRepPress,
183
- variant = 'full',
418
+ variant = 'expanded',
419
+ height = EXPANDED_HEIGHT,
420
+ scale = 'peak',
421
+ showNumbers = true,
184
422
  showInfo = true,
185
423
  className,
186
424
  ...props
187
425
  }: VelocityStripProps) {
188
- const maxVelocity = Math.max(...velocities, 0)
189
- const meanVelocity = calculateMeanVelocity(velocities)
190
- const loss = calculateVelocityLoss(velocities)
426
+ // A `set` descriptor derives its own done-velocity array; the legacy
427
+ // `velocities` path stays the source of truth otherwise. Every summary calc
428
+ // (mean / loss / zone) runs on this one array so the info row works either way.
429
+ const doneVelocities = set ? deriveDoneVelocities(set) : (velocities ?? [])
430
+ const slots: VelocitySlot[] = set
431
+ ? buildSlots(set)
432
+ : doneVelocities.map((v, i) => ({
433
+ kind: 'rep',
434
+ velocity: v,
435
+ leadingGap: i === 0 ? 0 : REP_GAP,
436
+ }))
437
+
438
+ const maxVelocity = Math.max(...doneVelocities, 0)
439
+ const meanVelocity = calculateMeanVelocity(doneVelocities)
440
+ const loss = calculateVelocityLoss(doneVelocities)
441
+
442
+ // The framed chart (raised box, labels, info) vs the bare spotlight strip is the
443
+ // only fork in the `expanded` variant — keyed by whether any chrome is requested.
444
+ const framed = showNumbers || showInfo
445
+ const scaleDenom = scaleDenominator(scale, maxVelocity)
191
446
 
192
447
  const hasZones = zones != null && zones.length > 0
193
448
  const barColorFor = (v: number): string =>
194
449
  hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
450
+ const slotColor = (slot: VelocitySlot): string => {
451
+ if (slot.kind === 'rep') return barColorFor(slot.velocity ?? 0)
452
+ if (slot.kind === 'todo') return TODO_COLOR
453
+ return SET_STRIP_VARIABLE_COLOR
454
+ }
195
455
  const meanZone = hasZones
196
456
  ? (classifyBand(meanVelocity, zones)?.label ?? '')
197
457
  : getVelocityZoneName(meanVelocity)
198
458
 
199
459
  const prefersReducedMotion = usePrefersReducedMotion()
200
- const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
460
+ const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
201
461
  const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
202
462
  const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
203
463
  const [liveScale] = useState(() => new Animated.Value(1))
204
464
 
205
465
  // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
206
- const liveVelocity = liveRepIndex != null ? velocities[liveRepIndex] : undefined
466
+ const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
207
467
  const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
208
468
  useEffect(() => {
209
- if (variant === 'mini' || liveRepIndex == null || liveVelocity == null) return
469
+ if (variant !== 'expanded' || !framed || liveRepIndex == null || liveVelocity == null) return
210
470
  if (prefersReducedMotion) {
211
471
  liveScale.setValue(1)
212
472
  return
@@ -236,14 +496,14 @@ export function VelocityStrip({
236
496
  useNativeDriver: true,
237
497
  }).start()
238
498
  }
239
- }, [variant, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
499
+ }, [variant, framed, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
240
500
 
241
501
  useEffect(() => {
242
- if (variant === 'mini') return
502
+ if (variant !== 'expanded' || !framed) return
243
503
 
244
504
  Animated.parallel([
245
505
  Animated.timing(heightAnim, {
246
- toValue: expanded ? 60 : 3,
506
+ toValue: expanded ? height : 3,
247
507
  duration: ANIMATION_DURATION,
248
508
  easing: ANIMATION_EASING,
249
509
  useNativeDriver: false,
@@ -261,42 +521,148 @@ export function VelocityStrip({
261
521
  useNativeDriver: false,
262
522
  }),
263
523
  ]).start()
264
- }, [expanded, variant, heightAnim, labelOpacity, infoOpacity])
524
+ }, [expanded, variant, framed, height, heightAnim, labelOpacity, infoOpacity])
525
+
526
+ // Nothing to draw: neither a legacy velocity array nor a set descriptor.
527
+ if (set == null && velocities == null) return null
528
+
529
+ const repCount = doneVelocities.length
530
+ const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
265
531
 
266
532
  if (variant === 'mini') {
267
533
  const { style: externalStyle, ...restProps } = props
534
+ // The container keeps the uniform REP_GAP (so a no-`set` strip is byte-identical
535
+ // to prior releases and holds HTML-parity); per-slot `marginLeft` carries only
536
+ // the EXTRA spacing for the WIDE notch that chunks drop / myo / cluster sets.
268
537
  return (
269
538
  <View
270
539
  className={className}
271
- style={[{ flexDirection: 'row', height: 3, gap: 2, borderRadius: 2, overflow: 'hidden' }, externalStyle]}
540
+ style={[
541
+ { flexDirection: 'row', height: 3, gap: REP_GAP, borderRadius: 2, overflow: 'hidden' },
542
+ externalStyle,
543
+ ]}
272
544
  accessibilityRole="image"
273
- accessibilityLabel={`Velocity strip, ${velocities.length} reps`}
545
+ accessibilityLabel={miniLabel}
274
546
  testID="velocity-strip-mini"
275
547
  {...restProps}
276
548
  >
277
- {velocities.map((v, i) => (
549
+ {slots.map((slot, i) => (
278
550
  <View
279
551
  key={i}
280
552
  style={{
281
553
  flex: 1,
282
- backgroundColor: barColorFor(v),
554
+ backgroundColor: slotColor(slot),
283
555
  borderRadius: 1,
284
556
  minWidth: 4,
285
557
  height: '100%' as unknown as number,
558
+ // The container's uniform 2px gap covers rep spacing; a wide slot adds
559
+ // only the EXTRA (WIDE_GAP − REP_GAP) so the notch totals WIDE_GAP.
560
+ marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
561
+ ...(slot.kind === 'continue'
562
+ ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
563
+ : {}),
286
564
  }}
287
565
  accessibilityElementsHidden
288
- testID={`velocity-bar-${i}`}
566
+ testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
289
567
  />
290
568
  ))}
291
569
  </View>
292
570
  )
293
571
  }
294
572
 
295
- const stripLabel = `Velocity chart for set, ${velocities.length} reps, tap to ${expanded ? 'collapse' : 'expand'}`
573
+ // Bare `expanded` strip (both chrome flags off): the velocity-HEIGHT spotlight
574
+ // px bar heights, no raised box / labels / info row / animation. Set-type gaps carry
575
+ // over from the slot model exactly as in `mini` (container REP_GAP + per-slot extra
576
+ // for a WIDE notch); planned / variable / continue slots draw as stubs.
577
+ if (!framed) {
578
+ const { style: externalStyle, ...restProps } = props
579
+ return (
580
+ <View
581
+ className={className}
582
+ style={[
583
+ { flexDirection: 'row', height, gap: REP_GAP, alignItems: 'flex-end' },
584
+ externalStyle,
585
+ ]}
586
+ accessibilityRole="image"
587
+ accessibilityLabel={miniLabel}
588
+ testID="velocity-strip-compact"
589
+ {...restProps}
590
+ >
591
+ {slots.map((slot, i) => (
592
+ <View
593
+ key={i}
594
+ style={{
595
+ flex: 1,
596
+ minWidth: 4,
597
+ height: bareSlotHeight(slot, height, scaleDenom),
598
+ // Top-rounded to match the framed chart's bars (the "rounded tops from
599
+ // the numbers one"); square bottoms since bars sit on the baseline.
600
+ borderTopLeftRadius: 2,
601
+ borderTopRightRadius: 2,
602
+ backgroundColor: slotColor(slot),
603
+ marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
604
+ ...(slot.kind === 'continue'
605
+ ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
606
+ : {}),
607
+ }}
608
+ accessibilityElementsHidden
609
+ testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
610
+ />
611
+ ))}
612
+ </View>
613
+ )
614
+ }
615
+
616
+ const stripLabel = set
617
+ ? `${setAccessibilityLabel(set, repCount)}, tap to ${expanded ? 'collapse' : 'expand'}`
618
+ : `Velocity chart for set, ${repCount} reps, tap to ${expanded ? 'collapse' : 'expand'}`
296
619
  // When onToggle wraps the strip or individual reps are interactive, the container itself is not a button
297
620
  const hasInteractiveContainer = onToggle != null
298
621
  const hasInteractiveReps = onRepPress != null && expanded
299
622
 
623
+ // Expanded set-type bars: `straight` is the active-set spotlight (velocity-height
624
+ // done reps + short grey planned stubs); the advanced types render as a short,
625
+ // mini-style encoding (the gaps + colors carry identity, height is uniform).
626
+ const setBars =
627
+ set != null &&
628
+ slots.map((slot, i) => {
629
+ const isStraightRep = set.type === 'straight' && slot.kind === 'rep'
630
+ const heightPct = isStraightRep
631
+ ? scaleDenom > 0
632
+ ? Math.round(((slot.velocity ?? 0) / scaleDenom) * 100)
633
+ : 0
634
+ : set.type === 'straight'
635
+ ? EXPANDED_TODO_STUB_PCT
636
+ : EXPANDED_ENCODED_PCT
637
+ return (
638
+ <View
639
+ key={i}
640
+ style={{
641
+ flex: 1,
642
+ height: '100%',
643
+ justifyContent: 'flex-end',
644
+ marginLeft: slot.leadingGap,
645
+ }}
646
+ >
647
+ <View
648
+ style={{
649
+ height: `${heightPct}%`,
650
+ minHeight: 2,
651
+ borderTopLeftRadius: 2,
652
+ borderTopRightRadius: 2,
653
+ backgroundColor: slotColor(slot),
654
+ ...(slot.kind === 'continue'
655
+ ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
656
+ : {}),
657
+ }}
658
+ testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
659
+ accessibilityRole="image"
660
+ accessibilityLabel={slotAccessibilityLabel(slot, i)}
661
+ />
662
+ </View>
663
+ )
664
+ })
665
+
300
666
  const stripContent = (
301
667
  <Animated.View
302
668
  className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
@@ -310,7 +676,9 @@ export function VelocityStrip({
310
676
  borderRadius: expanded ? 6 : 2,
311
677
  paddingTop: expanded ? 16 : 0,
312
678
  paddingBottom: expanded ? 24 : 0,
313
- paddingHorizontal: expanded ? 6 : 0,
679
+ // No horizontal inset the framed chart's bars span full width, matching
680
+ // the bare spotlight and mini. Numbers/info sit in the vertical padding.
681
+ paddingHorizontal: 0,
314
682
  paddingVertical: expanded ? undefined : 8,
315
683
  overflow: expanded ? 'visible' : 'hidden',
316
684
  },
@@ -320,86 +688,106 @@ export function VelocityStrip({
320
688
  testID="velocity-strip"
321
689
  {...props}
322
690
  >
323
- <View
324
- style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
325
- >
326
- {velocities.map((v, i) => {
327
- const barBackground = barColorFor(v)
328
- // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
329
- // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
330
- const barHeightPct =
331
- maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
332
- const isLive = liveRepIndex === i
333
- const liveLabelSuffix = isLive
334
- ? isNewPeak
335
- ? ', latest rep, new set peak'
336
- : ', latest rep'
337
- : ''
338
-
339
- const barStyle: ViewStyle = expanded
340
- ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: barBackground }
341
- : {
342
- minHeight: '100%' as unknown as number,
343
- borderTopLeftRadius: 2,
344
- borderTopRightRadius: 2,
345
- borderBottomLeftRadius: 0,
346
- borderBottomRightRadius: 0,
347
- backgroundColor: barBackground,
691
+ <View style={{ flexDirection: 'row', flex: 1, gap: set ? 0 : 2, alignItems: 'flex-end' }}>
692
+ {set
693
+ ? setBars
694
+ : doneVelocities.map((v, i) => {
695
+ const barBackground = barColorFor(v)
696
+ // Guard all-zero velocities (idle / pre-rep): a 0 denominator makes
697
+ // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
698
+ const barHeightPct = scaleDenom > 0 ? Math.round((v / scaleDenom) * 100) : 0
699
+ const isLive = liveRepIndex === i
700
+ const liveLabelSuffix = isLive
701
+ ? isNewPeak
702
+ ? ', latest rep, new set peak'
703
+ : ', latest rep'
704
+ : ''
705
+
706
+ const barStyle: ViewStyle = expanded
707
+ ? {
708
+ height: `${barHeightPct}%`,
709
+ minHeight: 2,
710
+ borderTopLeftRadius: 2,
711
+ borderTopRightRadius: 2,
712
+ backgroundColor: barBackground,
713
+ }
714
+ : {
715
+ minHeight: '100%' as unknown as number,
716
+ borderTopLeftRadius: 2,
717
+ borderTopRightRadius: 2,
718
+ borderBottomLeftRadius: 0,
719
+ borderBottomRightRadius: 0,
720
+ backgroundColor: barBackground,
721
+ }
722
+
723
+ const barInner = isLive ? (
724
+ <Animated.View
725
+ style={[barStyle, { transform: [{ scale: liveScale }] }]}
726
+ testID={`velocity-bar-${i}`}
727
+ accessibilityRole="image"
728
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
729
+ />
730
+ ) : (
731
+ <View
732
+ style={barStyle}
733
+ testID={`velocity-bar-${i}`}
734
+ accessibilityRole="image"
735
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
736
+ />
737
+ )
738
+
739
+ const bar = (
740
+ <View
741
+ key={i}
742
+ style={{
743
+ flex: 1,
744
+ height: '100%',
745
+ justifyContent: 'flex-end',
746
+ position: 'relative',
747
+ }}
748
+ >
749
+ {expanded && showNumbers && (
750
+ <Animated.View
751
+ style={{
752
+ opacity: labelOpacity,
753
+ alignItems: 'center',
754
+ position: 'absolute',
755
+ top: -13,
756
+ left: 0,
757
+ right: 0,
758
+ }}
759
+ accessibilityElementsHidden
760
+ >
761
+ <Text
762
+ className="text-text-secondary"
763
+ style={{ fontSize: 8, fontWeight: '600' }}
764
+ testID={`velocity-label-${i}`}
765
+ >
766
+ {formatVelocity(v)}
767
+ </Text>
768
+ </Animated.View>
769
+ )}
770
+ {barInner}
771
+ </View>
772
+ )
773
+
774
+ if (onRepPress && expanded) {
775
+ return (
776
+ <Pressable
777
+ key={i}
778
+ style={{ flex: 1, height: '100%' }}
779
+ onPress={() => onRepPress(i, v)}
780
+ accessibilityRole="button"
781
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
782
+ testID={`velocity-bar-pressable-${i}`}
783
+ >
784
+ {bar}
785
+ </Pressable>
786
+ )
348
787
  }
349
788
 
350
- const barInner = isLive ? (
351
- <Animated.View
352
- style={[barStyle, { transform: [{ scale: liveScale }] }]}
353
- testID={`velocity-bar-${i}`}
354
- accessibilityRole="image"
355
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
356
- />
357
- ) : (
358
- <View
359
- style={barStyle}
360
- testID={`velocity-bar-${i}`}
361
- accessibilityRole="image"
362
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
363
- />
364
- )
365
-
366
- const bar = (
367
- <View
368
- key={i}
369
- style={{ flex: 1, height: '100%', justifyContent: 'flex-end', position: 'relative' }}
370
- >
371
- {expanded && (
372
- <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
373
- <Text
374
- className="text-text-secondary"
375
- style={{ fontSize: 8, fontWeight: '600' }}
376
- testID={`velocity-label-${i}`}
377
- >
378
- {formatVelocity(v)}
379
- </Text>
380
- </Animated.View>
381
- )}
382
- {barInner}
383
- </View>
384
- )
385
-
386
- if (onRepPress && expanded) {
387
- return (
388
- <Pressable
389
- key={i}
390
- style={{ flex: 1, height: '100%' }}
391
- onPress={() => onRepPress(i, v)}
392
- accessibilityRole="button"
393
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
394
- testID={`velocity-bar-pressable-${i}`}
395
- >
396
- {bar}
397
- </Pressable>
398
- )
399
- }
400
-
401
- return bar
402
- })}
789
+ return bar
790
+ })}
403
791
  </View>
404
792
  {expanded && showInfo && (
405
793
  <Animated.View