@titan-design/react-ui 0.6.0 → 0.8.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 (258) 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 +956 -128
  6. package/dist/index.d.ts +956 -128
  7. package/dist/index.js +2873 -981
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2826 -982
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-MO0JCySL.d.mts → pages-DCFBqp79.d.mts} +289 -57
  12. package/dist/{pages-D7Jlssvp.d.ts → pages-_fI3-x7Z.d.ts} +289 -57
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1659 -799
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1660 -800
  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/CircularTimer/CircularTimer.stories.tsx +90 -0
  34. package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
  35. package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
  36. package/src/components/custom/CircularTimer/index.ts +1 -0
  37. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  38. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  39. package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
  40. package/src/components/custom/Metric/Metric.stories.tsx +1 -1
  41. package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
  42. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  43. package/src/components/custom/Table/Table.stories.tsx +1 -1
  44. package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
  45. package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
  46. package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
  47. package/src/components/custom/TimerReadout/index.ts +1 -0
  48. package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
  49. package/src/components/custom/Typography/Typography.stories.tsx +1 -1
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +9 -15
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
  54. package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
  55. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
  56. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
  57. package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
  58. package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
  59. package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
  60. package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
  61. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
  62. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
  63. package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
  64. package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
  65. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
  66. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
  67. package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -5
  68. package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
  69. package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
  70. package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
  71. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
  72. package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
  73. package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
  74. package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
  75. package/src/components/custom/Workout/FatigueMeter.test.tsx +85 -0
  76. package/src/components/custom/Workout/FatigueMeter.tsx +86 -0
  77. package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
  78. package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
  79. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
  80. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
  81. package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
  82. package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
  84. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
  85. package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
  86. package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
  87. package/src/components/custom/Workout/MetricTiles.tsx +48 -0
  88. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
  89. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
  90. package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
  91. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
  92. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
  93. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -6
  94. package/src/components/custom/Workout/README.md +193 -4
  95. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
  96. package/src/components/custom/Workout/RestTimer.stories.tsx +133 -3
  97. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  98. package/src/components/custom/Workout/RestTimer.tsx +155 -59
  99. package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
  100. package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
  101. package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
  102. package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
  103. package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
  104. package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
  105. package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
  106. package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
  107. package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
  108. package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
  109. package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
  110. package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
  111. package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
  112. package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
  113. package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
  114. package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
  115. package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
  116. package/src/components/custom/Workout/SessionHeader.tsx +152 -0
  117. package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
  118. package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
  119. package/src/components/custom/Workout/SessionRail.tsx +126 -0
  120. package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
  121. package/src/components/custom/Workout/SetBar.test.tsx +123 -0
  122. package/src/components/custom/Workout/SetBar.tsx +171 -0
  123. package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
  124. package/src/components/custom/Workout/SetRow.test.tsx +94 -166
  125. package/src/components/custom/Workout/SetRow.tsx +169 -232
  126. package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
  127. package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
  128. package/src/components/custom/Workout/SetStrip.tsx +71 -0
  129. package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
  130. package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
  131. package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
  132. package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
  133. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
  134. package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
  135. package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
  136. package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
  137. package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
  138. package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
  139. package/src/components/custom/Workout/StatusPill.tsx +88 -0
  140. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
  141. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
  142. package/src/components/custom/Workout/TempoBar.stories.tsx +70 -1
  143. package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
  144. package/src/components/custom/Workout/TempoBar.tsx +107 -22
  145. package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
  146. package/src/components/custom/Workout/TempoDisplay.tsx +23 -33
  147. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
  148. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
  149. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
  150. package/src/components/custom/Workout/VelocityStrip.stories.tsx +262 -56
  151. package/src/components/custom/Workout/VelocityStrip.test.tsx +440 -141
  152. package/src/components/custom/Workout/VelocityStrip.tsx +776 -141
  153. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
  154. package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
  155. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
  156. package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
  157. package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
  158. package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
  159. package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
  160. package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
  161. package/src/components/custom/Workout/ZoneTrack.stories.tsx +139 -0
  162. package/src/components/custom/Workout/ZoneTrack.test.tsx +234 -0
  163. package/src/components/custom/Workout/ZoneTrack.tsx +373 -0
  164. package/src/components/custom/Workout/index.ts +55 -2
  165. package/src/components/custom/Workout/metricText.stories.tsx +69 -0
  166. package/src/components/custom/Workout/metricText.tsx +34 -0
  167. package/src/components/custom/Workout/paceTone.test.ts +29 -0
  168. package/src/components/custom/Workout/paceTone.ts +27 -0
  169. package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
  170. package/src/components/custom/index.ts +2 -0
  171. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  172. package/src/components/icons/icons.stories.tsx +67 -2
  173. package/src/components/icons/icons.test.tsx +32 -2
  174. package/src/components/icons/icons.tsx +108 -0
  175. package/src/components/shell/BrandLockup.stories.tsx +3 -3
  176. package/src/components/shell/DashboardShell.stories.tsx +65 -0
  177. package/src/components/shell/DashboardShell.test.tsx +42 -0
  178. package/src/components/shell/DashboardShell.tsx +74 -0
  179. package/src/components/shell/DeviceIndicator.stories.tsx +2 -2
  180. package/src/components/shell/DeviceMenu.stories.tsx +5 -5
  181. package/src/components/shell/DeviceRow.stories.tsx +3 -3
  182. package/src/components/shell/NavItem.stories.tsx +52 -0
  183. package/src/components/shell/NavItem.test.tsx +35 -0
  184. package/src/components/shell/NavItem.tsx +75 -0
  185. package/src/components/shell/README.md +30 -0
  186. package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
  187. package/src/components/shell/SessionStatePill.stories.tsx +3 -3
  188. package/src/components/shell/SessionStatePill.tsx +1 -1
  189. package/src/components/shell/SideNav.stories.tsx +81 -0
  190. package/src/components/shell/SideNav.test.tsx +51 -0
  191. package/src/components/shell/SideNav.tsx +74 -0
  192. package/src/components/shell/TopBar.stories.tsx +5 -5
  193. package/src/components/shell/index.ts +5 -0
  194. package/src/components/ui/alert/Alert.stories.tsx +86 -3
  195. package/src/components/ui/alert/Alert.test.tsx +52 -0
  196. package/src/components/ui/alert/Alert.tsx +54 -20
  197. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  198. package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
  199. package/src/components/ui/badge/Badge.stories.tsx +1 -1
  200. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
  201. package/src/components/ui/button/Button.stories.tsx +1 -1
  202. package/src/components/ui/card/Card.stories.tsx +1 -1
  203. package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
  204. package/src/components/ui/chip/Chip.stories.tsx +1 -1
  205. package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
  206. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  207. package/src/components/ui/divider/Divider.stories.tsx +1 -1
  208. package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
  209. package/src/components/ui/form-field/FormField.stories.tsx +1 -1
  210. package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
  211. package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
  212. package/src/components/ui/index.ts +1 -0
  213. package/src/components/ui/indicator/Indicator.stories.tsx +2 -2
  214. package/src/components/ui/indicator/Indicator.test.tsx +1 -0
  215. package/src/components/ui/indicator/Indicator.tsx +6 -1
  216. package/src/components/ui/input/Input.stories.tsx +1 -1
  217. package/src/components/ui/link/Link.stories.tsx +1 -1
  218. package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
  219. package/src/components/ui/menu/Menu.stories.tsx +1 -1
  220. package/src/components/ui/modal/Modal.stories.tsx +1 -1
  221. package/src/components/ui/pill/Pill.stories.tsx +1 -1
  222. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  223. package/src/components/ui/progress/Progress.stories.tsx +1 -1
  224. package/src/components/ui/progress/Progress.tsx +47 -22
  225. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  226. package/src/components/ui/section/Section.stories.tsx +1 -1
  227. package/src/components/ui/select/Select.stories.tsx +1 -1
  228. package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
  229. package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
  230. package/src/components/ui/stack/Stack.stories.tsx +1 -1
  231. package/src/components/ui/surface/Surface.stories.tsx +1 -1
  232. package/src/components/ui/switch/Switch.stories.tsx +1 -1
  233. package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
  234. package/src/components/ui/tile/Tile.stories.tsx +79 -0
  235. package/src/components/ui/tile/Tile.test.tsx +48 -0
  236. package/src/components/ui/tile/Tile.tsx +64 -0
  237. package/src/components/ui/tile/index.ts +2 -0
  238. package/src/components/ui/toast/Toast.stories.tsx +1 -1
  239. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  240. package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
  241. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
  242. package/src/hooks/index.ts +2 -0
  243. package/src/hooks/useTimer.test.ts +150 -0
  244. package/src/hooks/useTimer.ts +77 -0
  245. package/src/index.ts +3 -0
  246. package/src/pages.ts +1 -1
  247. package/src/stories/PresetShowcase.stories.tsx +1 -1
  248. package/src/stories/ThemePresets.stories.tsx +1 -1
  249. package/src/theme/ColorSystem.stories.tsx +1 -1
  250. package/src/theme/Colors.stories.tsx +5 -8
  251. package/src/theme/config.ts +4 -0
  252. package/src/theme/elevation.stories.tsx +1 -1
  253. package/src/theme/global.css +5 -1
  254. package/src/theme/shadows.stories.tsx +1 -1
  255. package/src/theme/tokens/primitives.ts +11 -107
  256. package/src/theme/tokens/semantic.ts +8 -0
  257. package/src/theme/workout-tokens.ts +11 -7
  258. 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,45 @@ 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}. `hero`
88
+ * — the across-the-room, single-set wall treatment: tall bars, a per-bar m/s value
89
+ * label, a dashed running-best reference line, and dashed placeholders for the reps
90
+ * still to come (see {@link targetReps}). Reuses the zone scale and the live-rep pop.
91
+ */
92
+ variant?: 'mini' | 'expanded' | 'hero'
93
+ /** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
94
+ height?: number
95
+ /**
96
+ * `hero` only: the set's planned rep count. Reps beyond {@link velocities} draw as
97
+ * dashed placeholder stubs so the wall reads "3 of 8 done" at a glance. Ignored
98
+ * (and no placeholders drawn) when absent or ≤ the performed-rep count.
99
+ */
100
+ targetReps?: number
101
+ /**
102
+ * `expanded` / `hero` bar scaling. `peak` (default) scales to the set's own max
103
+ * (+15% headroom). `fixed` scales to a fixed velocity ceiling so bar heights read
104
+ * the same absolute velocity across sets (the spotlight).
105
+ */
106
+ scale?: 'peak' | 'fixed'
107
+ /** `expanded` framed chart: per-bar m/s labels. Default true. */
108
+ showNumbers?: boolean
109
+ /** `expanded` framed chart: the mean/loss info row. Default true. */
56
110
  showInfo?: boolean
57
111
  className?: string
58
112
  }
@@ -62,26 +116,25 @@ export interface VelocityStripProps extends ViewProps {
62
116
  const VEL_COLORS = WORKOUT_TOKENS.scale
63
117
 
64
118
  /**
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.
119
+ * Map a velocity-zone id (WA's 5-band taxonomy) directly onto the 6-stop
120
+ * `sequentialEffort` ramp. The default strip uses the 4-color {@link VEL_COLORS}
121
+ * scale (a subsample of the same ramp), but the 5-band taxonomy has one more
122
+ * level than that scale carries, so it samples 5 stops: the top four align with
123
+ * the default scale (speed/power/strengthSpeed/maximalStrength = green/gold/
124
+ * orange/red) and `grinding` takes the ramp's darker red. `maximalStrength` and
125
+ * `grinding` therefore stay DISTINCT — they no longer collapse onto one red for
126
+ * lack of a 5th hue. An exercise moving from default to profile-derived zones
127
+ * never shifts its fast-end colors.
75
128
  *
76
129
  * Unknown ids fall back to `green` (matching the historical default-path
77
130
  * fallback), so a forward-compatible band id never renders an empty bar.
78
131
  */
79
- const zoneIdToScaleToken: Record<string, keyof typeof VEL_COLORS> = {
80
- speed: 'green',
81
- power: 'yellow',
82
- strengthSpeed: 'orange',
83
- maximalStrength: 'red',
84
- grinding: 'red',
132
+ const bandIdToEffortColor: Record<string, string> = {
133
+ speed: sequentialEffort[0], // green-300
134
+ power: sequentialEffort[2], // amber-300 (gold)
135
+ strengthSpeed: sequentialEffort[3], // orange-400
136
+ maximalStrength: sequentialEffort[4], // red-600
137
+ grinding: sequentialEffort[5], // red-700 — the distinct 5th band
85
138
  }
86
139
 
87
140
  // --- Default (no-zones) scale ------------------------------------------------
@@ -131,7 +184,7 @@ export function calculateMeanVelocity(velocities: number[]): number {
131
184
  /** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
132
185
  function classifyBand(
133
186
  velocity: number,
134
- bands: readonly VelocityZoneBandProp[],
187
+ bands: readonly VelocityZoneBandProp[]
135
188
  ): VelocityZoneBandProp | undefined {
136
189
  for (const band of bands) {
137
190
  if (band.max === null || velocity < band.max) return band
@@ -140,8 +193,7 @@ function classifyBand(
140
193
  }
141
194
 
142
195
  function bandColor(band: VelocityZoneBandProp): string {
143
- const token = zoneIdToScaleToken[band.id]
144
- return token ? VEL_COLORS[token] : VEL_COLORS.green
196
+ return bandIdToEffortColor[band.id] ?? VEL_COLORS.green
145
197
  }
146
198
 
147
199
  function getLossStyle(loss: number): Record<string, string> | null {
@@ -150,6 +202,219 @@ function getLossStyle(loss: number): Record<string, string> | null {
150
202
  return null
151
203
  }
152
204
 
205
+ // --- Set-type slot model -----------------------------------------------------
206
+ // The strength-training set vocabulary (operator-approved in the Set Modalities
207
+ // exploration): one strip encoding for every per-rep set type. A set resolves to
208
+ // an ordered list of slots; the slot `kind` picks the color and `leadingGap`
209
+ // encodes the chunk pattern (rep gap vs the wide notch that splits drop sub-loads
210
+ // / myo clusters / cluster intra-rests). Set-LEVEL types (pyramid / superset /
211
+ // tempo) belong to the heading, not the strip.
212
+
213
+ /**
214
+ * A structured strength-training set. Supply as {@link VelocityStripProps.set} to
215
+ * render its set-type encoding; the done-velocity array (used for the mean / loss
216
+ * / zone summary) is derived from the type's velocity fields.
217
+ */
218
+ export type VelocitySet =
219
+ /** Fixed reps at a fixed load: done reps colored + a grey todo remainder to `planned`. */
220
+ | { type: 'straight'; velocities: number[]; planned?: number }
221
+ /** Bounded reps: committed slots `0..floor`, then a cyan variable window `floor..max`. */
222
+ | { type: 'range'; velocities: number[]; floor: number; max: number }
223
+ /** As-many-reps-as-possible: done reps + a trailing cyan "continue" slot that never closes. */
224
+ | { type: 'amrap'; velocities: number[]; target?: number }
225
+ /** Load drops with no rest: each sub-load's reps, split by a WIDE notch gap. */
226
+ | { type: 'drop'; subloads: number[][] }
227
+ /** Rest-pause: an activation chunk + mini-clusters split by WIDE gaps; `open` adds a cyan continue. */
228
+ | { type: 'myo'; activation: number[]; clusters: number[][]; open?: boolean }
229
+ /** Fixed count broken by intra-set rests: reps grouped by `groupSize` with WIDE intra-rest gaps. */
230
+ | { type: 'cluster'; velocities: number[]; groupSize: number; planned?: number }
231
+
232
+ /** One drawn cell of the strip. `kind` picks the color; `leadingGap` the spacing before it. */
233
+ interface VelocitySlot {
234
+ kind: 'rep' | 'todo' | 'variable' | 'continue'
235
+ velocity?: number
236
+ leadingGap: number
237
+ }
238
+
239
+ /** Butted reps carry this gap; chunk boundaries carry {@link WIDE_GAP}. */
240
+ const REP_GAP = 2
241
+ /**
242
+ * The between-group notch (drop sub-loads / myo clusters / cluster intra-rests): a
243
+ * fixed 8px total gap that reads as a group break without dominating a narrow strip.
244
+ * 4× the rep gap — enough to separate groups at session-rail scale AND wall scale
245
+ * without the notch ballooning at large widths. In the mini variant the container's
246
+ * 2px gap covers part of it, so per-slot margin adds the remaining 6px (2 + 6 = 8).
247
+ */
248
+ const WIDE_GAP = 8
249
+
250
+ /** Grey fill for planned-but-unperformed reps (charcoal placeholder, literal hex). */
251
+ const TODO_COLOR = primitiveColors.charcoal[300]
252
+ /** The thin cyan-800 outline on the open-ended "continue" slot — reads as "keep going". */
253
+ const CONTINUE_OUTLINE = primitiveRamps.cyan[800]
254
+
255
+ /** The done-velocity array a set contributes to the mean / loss / zone summary. */
256
+ function deriveDoneVelocities(set: VelocitySet): number[] {
257
+ switch (set.type) {
258
+ case 'drop':
259
+ return set.subloads.flat()
260
+ case 'myo':
261
+ return [...set.activation, ...set.clusters.flat()]
262
+ default:
263
+ return set.velocities
264
+ }
265
+ }
266
+
267
+ /** Flatten velocity chunks into rep slots, carving a WIDE gap before each chunk but the first. */
268
+ function chunkedRepSlots(chunks: number[][]): VelocitySlot[] {
269
+ const slots: VelocitySlot[] = []
270
+ chunks.forEach((chunk) => {
271
+ chunk.forEach((velocity, ri) => {
272
+ const first = slots.length === 0
273
+ slots.push({ kind: 'rep', velocity, leadingGap: first ? 0 : ri === 0 ? WIDE_GAP : REP_GAP })
274
+ })
275
+ })
276
+ return slots
277
+ }
278
+
279
+ /** Resolve a set to its ordered slot list — the exact vocabulary of the Set Modalities sheet. */
280
+ function buildSlots(set: VelocitySet): VelocitySlot[] {
281
+ switch (set.type) {
282
+ case 'straight': {
283
+ const total = Math.max(set.velocities.length, set.planned ?? set.velocities.length)
284
+ return Array.from({ length: total }, (_, i) => {
285
+ const done = i < set.velocities.length
286
+ return {
287
+ kind: done ? 'rep' : 'todo',
288
+ velocity: done ? set.velocities[i] : undefined,
289
+ leadingGap: i === 0 ? 0 : REP_GAP,
290
+ }
291
+ })
292
+ }
293
+ case 'range': {
294
+ const total = Math.max(set.max, set.velocities.length)
295
+ return Array.from({ length: total }, (_, i) => {
296
+ const kind: VelocitySlot['kind'] =
297
+ i < set.velocities.length ? 'rep' : i < set.floor ? 'todo' : 'variable'
298
+ return {
299
+ kind,
300
+ velocity: kind === 'rep' ? set.velocities[i] : undefined,
301
+ leadingGap: i === 0 ? 0 : REP_GAP,
302
+ }
303
+ })
304
+ }
305
+ case 'amrap': {
306
+ const reps = set.velocities.map<VelocitySlot>((velocity, i) => ({
307
+ kind: 'rep',
308
+ velocity,
309
+ leadingGap: i === 0 ? 0 : REP_GAP,
310
+ }))
311
+ reps.push({ kind: 'continue', leadingGap: reps.length === 0 ? 0 : REP_GAP })
312
+ return reps
313
+ }
314
+ case 'drop':
315
+ return chunkedRepSlots(set.subloads)
316
+ case 'myo': {
317
+ const slots = chunkedRepSlots([set.activation, ...set.clusters])
318
+ if (set.open) slots.push({ kind: 'continue', leadingGap: slots.length === 0 ? 0 : REP_GAP })
319
+ return slots
320
+ }
321
+ case 'cluster': {
322
+ const total = Math.max(set.velocities.length, set.planned ?? set.velocities.length)
323
+ return Array.from({ length: total }, (_, i) => {
324
+ const done = i < set.velocities.length
325
+ const boundary = i > 0 && i % set.groupSize === 0
326
+ return {
327
+ kind: done ? 'rep' : 'todo',
328
+ velocity: done ? set.velocities[i] : undefined,
329
+ leadingGap: i === 0 ? 0 : boundary ? WIDE_GAP : REP_GAP,
330
+ }
331
+ })
332
+ }
333
+ }
334
+ }
335
+
336
+ /** A concise, set-type-aware summary for the strip's accessibility label. */
337
+ function setAccessibilityLabel(set: VelocitySet, repCount: number): string {
338
+ switch (set.type) {
339
+ case 'straight':
340
+ return `Velocity strip, straight set, ${repCount} reps`
341
+ case 'range':
342
+ return `Velocity strip, rep-range set, floor ${set.floor} to ${set.max}, ${repCount} reps done`
343
+ case 'amrap':
344
+ return `Velocity strip, AMRAP set, ${repCount} reps and counting`
345
+ case 'drop':
346
+ return `Velocity strip, drop set, ${set.subloads.length} loads, ${repCount} reps`
347
+ case 'myo':
348
+ return `Velocity strip, myo-reps set, ${set.clusters.length} clusters${set.open ? ', open' : ''}, ${repCount} reps`
349
+ case 'cluster':
350
+ return `Velocity strip, cluster set, groups of ${set.groupSize}, ${repCount} reps`
351
+ }
352
+ }
353
+
354
+ /** Per-slot accessibility label for the expanded set-type bars. */
355
+ function slotAccessibilityLabel(slot: VelocitySlot, index: number): string {
356
+ switch (slot.kind) {
357
+ case 'rep':
358
+ return `Rep ${index + 1}: ${formatVelocity(slot.velocity ?? 0)} meters per second`
359
+ case 'todo':
360
+ return `Rep ${index + 1}: planned`
361
+ case 'variable':
362
+ return `Rep ${index + 1}: variable`
363
+ case 'continue':
364
+ return 'Keep going'
365
+ }
366
+ }
367
+
368
+ /** Straight-set expanded stub height for a planned (todo) rep, as a % of the plot. */
369
+ const EXPANDED_TODO_STUB_PCT = 16
370
+ /** Expanded height for advanced set-types (drop / myo / cluster / range / amrap): a short mini-style bar. */
371
+ const EXPANDED_ENCODED_PCT = 45
372
+
373
+ /** Default framed `expanded` chart height (px). */
374
+ const EXPANDED_HEIGHT = 60
375
+ /**
376
+ * The `scale="fixed"` velocity ceiling (m/s). `peak` scales to the set's own max
377
+ * (+15% headroom); `fixed` uses this constant so a bar's height reads the same
378
+ * absolute velocity across sets (the spotlight).
379
+ */
380
+ const FIXED_MAX_VELOCITY = 1.15
381
+ /** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
382
+ const BARE_STUB_HEIGHT = 3
383
+
384
+ // --- Hero variant ------------------------------------------------------------
385
+ // The across-the-room, single-set wall treatment. Absorbs the R2 "HeroVelocityBars"
386
+ // candidate into VelocityStrip: tall bars + per-bar value labels + a dashed
387
+ // running-best reference line + dashed placeholders for the reps still to come.
388
+
389
+ /** Default `hero` plot height (px) — tall enough to read a set's velocity shape across a room. */
390
+ const HERO_HEIGHT = 220
391
+ /** Vertical band reserved above the tallest bar for its value label (px). */
392
+ const HERO_LABEL_HEADROOM = 20
393
+ /** Gap between hero bars (px) — a group-notch-free even rhythm at wall scale. */
394
+ const HERO_BAR_GAP = 8
395
+ /** Cap on a single hero bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
396
+ const HERO_BAR_MAX_WIDTH = 52
397
+ /** Top-corner radius on hero bars (px). */
398
+ const HERO_BAR_RADIUS = 5
399
+ /** Minimum drawn height of a performed hero bar (px) — a near-zero rep still reads as a rep. */
400
+ const HERO_MIN_BAR_HEIGHT = 4
401
+ /** Dashed-stub border for a planned-but-unperformed hero rep (charcoal, reads on a dark wall). */
402
+ const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
403
+ /** The dashed running-best reference line + its label (the lightest charcoal step). */
404
+ const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
405
+
406
+ /** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
407
+ function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
408
+ return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * 1.15
409
+ }
410
+
411
+ /** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
412
+ function bareSlotHeight(slot: VelocitySlot, height: number, denom: number): number {
413
+ if (slot.kind !== 'rep' || denom <= 0) return BARE_STUB_HEIGHT
414
+ const ratio = Math.min(1, (slot.velocity ?? 0) / denom)
415
+ return Math.max(BARE_STUB_HEIGHT, ratio * height)
416
+ }
417
+
153
418
  function getReducedMotionPreference(): boolean {
154
419
  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
155
420
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -173,40 +438,22 @@ const ANIMATION_DURATION = 400
173
438
  const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
174
439
  const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
175
440
 
176
- export function VelocityStrip({
177
- velocities,
178
- zones,
179
- liveRepIndex,
180
- expanded = false,
181
- onToggle,
182
- onRepPress,
183
- variant = 'full',
184
- showInfo = true,
185
- className,
186
- ...props
187
- }: VelocityStripProps) {
188
- const maxVelocity = Math.max(...velocities, 0)
189
- const meanVelocity = calculateMeanVelocity(velocities)
190
- const loss = calculateVelocityLoss(velocities)
191
-
192
- const hasZones = zones != null && zones.length > 0
193
- const barColorFor = (v: number): string =>
194
- hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
195
- const meanZone = hasZones
196
- ? (classifyBand(meanVelocity, zones)?.label ?? '')
197
- : getVelocityZoneName(meanVelocity)
198
-
441
+ /**
442
+ * The newest-rep entrance shared by the framed `expanded` chart and `hero`: a
443
+ * bounce when the rep is a new set peak, a pop otherwise. Honors reduced motion.
444
+ * Returns the scale `Animated.Value` for the live bar; inert while `active` is
445
+ * false or no live rep is present.
446
+ */
447
+ function useLiveRepPop(
448
+ active: boolean,
449
+ liveRepIndex: number | undefined,
450
+ liveVelocity: number | undefined,
451
+ isNewPeak: boolean
452
+ ): Animated.Value {
199
453
  const prefersReducedMotion = usePrefersReducedMotion()
200
- const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
201
- const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
202
- const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
203
454
  const [liveScale] = useState(() => new Animated.Value(1))
204
-
205
- // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
206
- const liveVelocity = liveRepIndex != null ? velocities[liveRepIndex] : undefined
207
- const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
208
455
  useEffect(() => {
209
- if (variant === 'mini' || liveRepIndex == null || liveVelocity == null) return
456
+ if (!active || liveRepIndex == null || liveVelocity == null) return
210
457
  if (prefersReducedMotion) {
211
458
  liveScale.setValue(1)
212
459
  return
@@ -236,14 +483,254 @@ export function VelocityStrip({
236
483
  useNativeDriver: true,
237
484
  }).start()
238
485
  }
239
- }, [variant, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
486
+ }, [active, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
487
+ return liveScale
488
+ }
489
+
490
+ interface HeroVelocityChartProps {
491
+ /** Performed per-rep mean concentric velocities (m/s). */
492
+ doneVelocities: number[]
493
+ /** Zone → color resolver, shared with the other variants (single-sources the scale). */
494
+ barColorFor: (velocity: number) => string
495
+ /** Bar-height scaling denominator (`peak` set-max or `fixed` ceiling). */
496
+ scaleDenom: number
497
+ /** Running-best velocity (the set peak) — drives the dashed reference line. */
498
+ referenceVelocity: number
499
+ liveRepIndex?: number
500
+ isNewPeak: boolean
501
+ targetReps?: number
502
+ height: number
503
+ className?: string
504
+ viewProps: ViewProps
505
+ }
506
+
507
+ /**
508
+ * The `hero` render: the across-the-room, single-set wall treatment. Tall bars +
509
+ * a per-bar value label + a dashed running-best reference line + dashed placeholders
510
+ * for the reps still to come. Reuses {@link barColorFor} (zone scale) and the
511
+ * {@link useLiveRepPop} entrance so it stays consistent with the framed chart.
512
+ */
513
+ function HeroVelocityChart({
514
+ doneVelocities,
515
+ barColorFor,
516
+ scaleDenom,
517
+ referenceVelocity,
518
+ liveRepIndex,
519
+ isNewPeak,
520
+ targetReps,
521
+ height,
522
+ className,
523
+ viewProps,
524
+ }: HeroVelocityChartProps) {
525
+ const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
526
+ const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
527
+
528
+ // Reserve a band above the tallest bar for its value label so a peak bar never clips.
529
+ const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
530
+ const barHeight = (velocity: number): number =>
531
+ scaleDenom > 0
532
+ ? Math.max(HERO_MIN_BAR_HEIGHT, Math.min(1, velocity / scaleDenom) * plotHeight)
533
+ : HERO_MIN_BAR_HEIGHT
534
+
535
+ const pendingCount = Math.max(0, (targetReps ?? 0) - doneVelocities.length)
536
+ const referencePx =
537
+ referenceVelocity > 0 && scaleDenom > 0
538
+ ? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
539
+ : 0
540
+
541
+ const repCount = doneVelocities.length
542
+ const total = Math.max(repCount, targetReps ?? repCount)
543
+ const label =
544
+ referenceVelocity > 0
545
+ ? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(referenceVelocity)} meters per second`
546
+ : `Velocity chart, ${repCount} of ${total} reps`
547
+
548
+ const { style: externalStyle, ...restProps } = viewProps
549
+
550
+ return (
551
+ <View
552
+ className={className}
553
+ style={[{ height }, externalStyle]}
554
+ accessibilityRole="image"
555
+ accessibilityLabel={label}
556
+ testID="velocity-strip-hero"
557
+ {...restProps}
558
+ >
559
+ <View
560
+ style={{
561
+ flex: 1,
562
+ flexDirection: 'row',
563
+ alignItems: 'flex-end',
564
+ gap: HERO_BAR_GAP,
565
+ position: 'relative',
566
+ borderBottomWidth: 2,
567
+ borderBottomColor: HERO_PENDING_COLOR,
568
+ }}
569
+ >
570
+ {/*
571
+ * Running-best reference line. Deliberately unlabeled: it sits at the tallest
572
+ * bar's top, and that bar already displays its velocity as its own value label,
573
+ * so a "best X.XX" tag would duplicate it — and would collide with that label
574
+ * whenever the peak bar is on the same side (declines peak-left, ends-on-best
575
+ * peak-right). The line alone reads as "how far has velocity fallen from best".
576
+ * The numeric best is in the container's accessibility label.
577
+ */}
578
+ {referencePx > 0 && (
579
+ <View
580
+ accessibilityElementsHidden
581
+ style={{
582
+ position: 'absolute',
583
+ left: 0,
584
+ right: 0,
585
+ bottom: referencePx,
586
+ borderTopWidth: 1,
587
+ borderStyle: 'dashed',
588
+ borderColor: HERO_REFERENCE_COLOR,
589
+ pointerEvents: 'none',
590
+ }}
591
+ testID="velocity-hero-reference"
592
+ />
593
+ )}
594
+
595
+ {doneVelocities.map((velocity, i) => {
596
+ const isLive = liveRepIndex === i
597
+ return (
598
+ <View
599
+ key={i}
600
+ accessibilityElementsHidden
601
+ style={{
602
+ flex: 1,
603
+ maxWidth: HERO_BAR_MAX_WIDTH,
604
+ height: '100%',
605
+ alignItems: 'center',
606
+ justifyContent: 'flex-end',
607
+ }}
608
+ >
609
+ <Text
610
+ className="text-text-primary"
611
+ style={{ fontSize: 12, fontWeight: '800', marginBottom: 4 }}
612
+ testID={`velocity-label-${i}`}
613
+ >
614
+ {formatVelocity(velocity)}
615
+ </Text>
616
+ <Animated.View
617
+ style={[
618
+ {
619
+ width: '100%',
620
+ height: barHeight(velocity),
621
+ borderTopLeftRadius: HERO_BAR_RADIUS,
622
+ borderTopRightRadius: HERO_BAR_RADIUS,
623
+ backgroundColor: barColorFor(velocity),
624
+ },
625
+ isLive ? { transform: [{ scale: liveScale }] } : null,
626
+ ]}
627
+ testID={`velocity-bar-${i}`}
628
+ />
629
+ </View>
630
+ )
631
+ })}
632
+
633
+ {Array.from({ length: pendingCount }, (_, i) => (
634
+ <View
635
+ key={`pending-${i}`}
636
+ accessibilityElementsHidden
637
+ style={{
638
+ flex: 1,
639
+ maxWidth: HERO_BAR_MAX_WIDTH,
640
+ height: '100%',
641
+ alignItems: 'center',
642
+ justifyContent: 'flex-end',
643
+ }}
644
+ >
645
+ <View
646
+ style={{
647
+ width: '100%',
648
+ height: HERO_MIN_BAR_HEIGHT * 3,
649
+ borderWidth: 1,
650
+ borderStyle: 'dashed',
651
+ borderColor: HERO_PENDING_COLOR,
652
+ borderTopLeftRadius: HERO_BAR_RADIUS,
653
+ borderTopRightRadius: HERO_BAR_RADIUS,
654
+ }}
655
+ testID="velocity-slot-todo"
656
+ />
657
+ </View>
658
+ ))}
659
+ </View>
660
+ </View>
661
+ )
662
+ }
663
+
664
+ export function VelocityStrip({
665
+ velocities,
666
+ set,
667
+ zones,
668
+ liveRepIndex,
669
+ expanded = true,
670
+ onToggle,
671
+ onRepPress,
672
+ variant = 'expanded',
673
+ height = EXPANDED_HEIGHT,
674
+ targetReps,
675
+ scale = 'peak',
676
+ showNumbers = true,
677
+ showInfo = true,
678
+ className,
679
+ ...props
680
+ }: VelocityStripProps) {
681
+ // A `set` descriptor derives its own done-velocity array; the legacy
682
+ // `velocities` path stays the source of truth otherwise. Every summary calc
683
+ // (mean / loss / zone) runs on this one array so the info row works either way.
684
+ const doneVelocities = set ? deriveDoneVelocities(set) : (velocities ?? [])
685
+ const slots: VelocitySlot[] = set
686
+ ? buildSlots(set)
687
+ : doneVelocities.map((v, i) => ({
688
+ kind: 'rep',
689
+ velocity: v,
690
+ leadingGap: i === 0 ? 0 : REP_GAP,
691
+ }))
692
+
693
+ const maxVelocity = Math.max(...doneVelocities, 0)
694
+ const meanVelocity = calculateMeanVelocity(doneVelocities)
695
+ const loss = calculateVelocityLoss(doneVelocities)
696
+
697
+ // The framed chart (raised box, labels, info) vs the bare spotlight strip is the
698
+ // only fork in the `expanded` variant — keyed by whether any chrome is requested.
699
+ const framed = showNumbers || showInfo
700
+ const scaleDenom = scaleDenominator(scale, maxVelocity)
701
+
702
+ const hasZones = zones != null && zones.length > 0
703
+ const barColorFor = (v: number): string =>
704
+ hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
705
+ const slotColor = (slot: VelocitySlot): string => {
706
+ if (slot.kind === 'rep') return barColorFor(slot.velocity ?? 0)
707
+ if (slot.kind === 'todo') return TODO_COLOR
708
+ return SET_STRIP_VARIABLE_COLOR
709
+ }
710
+ const meanZone = hasZones
711
+ ? (classifyBand(meanVelocity, zones)?.label ?? '')
712
+ : getVelocityZoneName(meanVelocity)
713
+
714
+ const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
715
+ const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
716
+ const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
717
+
718
+ // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
719
+ const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
720
+ const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
721
+ const liveScale = useLiveRepPop(
722
+ variant === 'expanded' && framed,
723
+ liveRepIndex,
724
+ liveVelocity,
725
+ isNewPeak
726
+ )
240
727
 
241
728
  useEffect(() => {
242
- if (variant === 'mini') return
729
+ if (variant !== 'expanded' || !framed) return
243
730
 
244
731
  Animated.parallel([
245
732
  Animated.timing(heightAnim, {
246
- toValue: expanded ? 60 : 3,
733
+ toValue: expanded ? height : 3,
247
734
  duration: ANIMATION_DURATION,
248
735
  easing: ANIMATION_EASING,
249
736
  useNativeDriver: false,
@@ -261,42 +748,168 @@ export function VelocityStrip({
261
748
  useNativeDriver: false,
262
749
  }),
263
750
  ]).start()
264
- }, [expanded, variant, heightAnim, labelOpacity, infoOpacity])
751
+ }, [expanded, variant, framed, height, heightAnim, labelOpacity, infoOpacity])
752
+
753
+ // Nothing to draw: neither a legacy velocity array nor a set descriptor.
754
+ if (set == null && velocities == null) return null
755
+
756
+ const repCount = doneVelocities.length
757
+ const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
758
+
759
+ if (variant === 'hero') {
760
+ // Hero's plot is far taller than the expanded chart; apply its own default when
761
+ // the caller left `height` at the shared 60px default.
762
+ const heroHeight = height === EXPANDED_HEIGHT ? HERO_HEIGHT : height
763
+ return (
764
+ <HeroVelocityChart
765
+ doneVelocities={doneVelocities}
766
+ barColorFor={barColorFor}
767
+ scaleDenom={scaleDenom}
768
+ referenceVelocity={maxVelocity}
769
+ liveRepIndex={liveRepIndex}
770
+ isNewPeak={isNewPeak}
771
+ targetReps={targetReps}
772
+ height={heroHeight}
773
+ className={className}
774
+ viewProps={props}
775
+ />
776
+ )
777
+ }
265
778
 
266
779
  if (variant === 'mini') {
267
780
  const { style: externalStyle, ...restProps } = props
781
+ // The container keeps the uniform REP_GAP (so a no-`set` strip is byte-identical
782
+ // to prior releases and holds HTML-parity); per-slot `marginLeft` carries only
783
+ // the EXTRA spacing for the WIDE notch that chunks drop / myo / cluster sets.
268
784
  return (
269
785
  <View
270
786
  className={className}
271
- style={[{ flexDirection: 'row', height: 3, gap: 2, borderRadius: 2, overflow: 'hidden' }, externalStyle]}
787
+ style={[
788
+ { flexDirection: 'row', height: 3, gap: REP_GAP, borderRadius: 2, overflow: 'hidden' },
789
+ externalStyle,
790
+ ]}
272
791
  accessibilityRole="image"
273
- accessibilityLabel={`Velocity strip, ${velocities.length} reps`}
792
+ accessibilityLabel={miniLabel}
274
793
  testID="velocity-strip-mini"
275
794
  {...restProps}
276
795
  >
277
- {velocities.map((v, i) => (
796
+ {slots.map((slot, i) => (
278
797
  <View
279
798
  key={i}
280
799
  style={{
281
800
  flex: 1,
282
- backgroundColor: barColorFor(v),
801
+ backgroundColor: slotColor(slot),
283
802
  borderRadius: 1,
284
803
  minWidth: 4,
285
804
  height: '100%' as unknown as number,
805
+ // The container's uniform 2px gap covers rep spacing; a wide slot adds
806
+ // only the EXTRA (WIDE_GAP − REP_GAP) so the notch totals WIDE_GAP.
807
+ marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
808
+ ...(slot.kind === 'continue'
809
+ ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
810
+ : {}),
286
811
  }}
287
812
  accessibilityElementsHidden
288
- testID={`velocity-bar-${i}`}
813
+ testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
289
814
  />
290
815
  ))}
291
816
  </View>
292
817
  )
293
818
  }
294
819
 
295
- const stripLabel = `Velocity chart for set, ${velocities.length} reps, tap to ${expanded ? 'collapse' : 'expand'}`
820
+ // Bare `expanded` strip (both chrome flags off): the velocity-HEIGHT spotlight
821
+ // px bar heights, no raised box / labels / info row / animation. Set-type gaps carry
822
+ // over from the slot model exactly as in `mini` (container REP_GAP + per-slot extra
823
+ // for a WIDE notch); planned / variable / continue slots draw as stubs.
824
+ if (!framed) {
825
+ const { style: externalStyle, ...restProps } = props
826
+ return (
827
+ <View
828
+ className={className}
829
+ style={[
830
+ { flexDirection: 'row', height, gap: REP_GAP, alignItems: 'flex-end' },
831
+ externalStyle,
832
+ ]}
833
+ accessibilityRole="image"
834
+ accessibilityLabel={miniLabel}
835
+ testID="velocity-strip-compact"
836
+ {...restProps}
837
+ >
838
+ {slots.map((slot, i) => (
839
+ <View
840
+ key={i}
841
+ style={{
842
+ flex: 1,
843
+ minWidth: 4,
844
+ height: bareSlotHeight(slot, height, scaleDenom),
845
+ // Top-rounded to match the framed chart's bars (the "rounded tops from
846
+ // the numbers one"); square bottoms since bars sit on the baseline.
847
+ borderTopLeftRadius: 2,
848
+ borderTopRightRadius: 2,
849
+ backgroundColor: slotColor(slot),
850
+ marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
851
+ ...(slot.kind === 'continue'
852
+ ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
853
+ : {}),
854
+ }}
855
+ accessibilityElementsHidden
856
+ testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
857
+ />
858
+ ))}
859
+ </View>
860
+ )
861
+ }
862
+
863
+ const stripLabel = set
864
+ ? `${setAccessibilityLabel(set, repCount)}, tap to ${expanded ? 'collapse' : 'expand'}`
865
+ : `Velocity chart for set, ${repCount} reps, tap to ${expanded ? 'collapse' : 'expand'}`
296
866
  // When onToggle wraps the strip or individual reps are interactive, the container itself is not a button
297
867
  const hasInteractiveContainer = onToggle != null
298
868
  const hasInteractiveReps = onRepPress != null && expanded
299
869
 
870
+ // Expanded set-type bars: `straight` is the active-set spotlight (velocity-height
871
+ // done reps + short grey planned stubs); the advanced types render as a short,
872
+ // mini-style encoding (the gaps + colors carry identity, height is uniform).
873
+ const setBars =
874
+ set != null &&
875
+ slots.map((slot, i) => {
876
+ const isStraightRep = set.type === 'straight' && slot.kind === 'rep'
877
+ const heightPct = isStraightRep
878
+ ? scaleDenom > 0
879
+ ? Math.round(((slot.velocity ?? 0) / scaleDenom) * 100)
880
+ : 0
881
+ : set.type === 'straight'
882
+ ? EXPANDED_TODO_STUB_PCT
883
+ : EXPANDED_ENCODED_PCT
884
+ return (
885
+ <View
886
+ key={i}
887
+ style={{
888
+ flex: 1,
889
+ height: '100%',
890
+ justifyContent: 'flex-end',
891
+ marginLeft: slot.leadingGap,
892
+ }}
893
+ >
894
+ <View
895
+ style={{
896
+ height: `${heightPct}%`,
897
+ minHeight: 2,
898
+ borderTopLeftRadius: 2,
899
+ borderTopRightRadius: 2,
900
+ backgroundColor: slotColor(slot),
901
+ ...(slot.kind === 'continue'
902
+ ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
903
+ : {}),
904
+ }}
905
+ testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
906
+ accessibilityRole="image"
907
+ accessibilityLabel={slotAccessibilityLabel(slot, i)}
908
+ />
909
+ </View>
910
+ )
911
+ })
912
+
300
913
  const stripContent = (
301
914
  <Animated.View
302
915
  className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
@@ -310,7 +923,9 @@ export function VelocityStrip({
310
923
  borderRadius: expanded ? 6 : 2,
311
924
  paddingTop: expanded ? 16 : 0,
312
925
  paddingBottom: expanded ? 24 : 0,
313
- paddingHorizontal: expanded ? 6 : 0,
926
+ // No horizontal inset the framed chart's bars span full width, matching
927
+ // the bare spotlight and mini. Numbers/info sit in the vertical padding.
928
+ paddingHorizontal: 0,
314
929
  paddingVertical: expanded ? undefined : 8,
315
930
  overflow: expanded ? 'visible' : 'hidden',
316
931
  },
@@ -320,86 +935,106 @@ export function VelocityStrip({
320
935
  testID="velocity-strip"
321
936
  {...props}
322
937
  >
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,
348
- }
938
+ <View style={{ flexDirection: 'row', flex: 1, gap: set ? 0 : 2, alignItems: 'flex-end' }}>
939
+ {set
940
+ ? setBars
941
+ : doneVelocities.map((v, i) => {
942
+ const barBackground = barColorFor(v)
943
+ // Guard all-zero velocities (idle / pre-rep): a 0 denominator makes
944
+ // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
945
+ const barHeightPct = scaleDenom > 0 ? Math.round((v / scaleDenom) * 100) : 0
946
+ const isLive = liveRepIndex === i
947
+ const liveLabelSuffix = isLive
948
+ ? isNewPeak
949
+ ? ', latest rep, new set peak'
950
+ : ', latest rep'
951
+ : ''
349
952
 
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
- )
953
+ const barStyle: ViewStyle = expanded
954
+ ? {
955
+ height: `${barHeightPct}%`,
956
+ minHeight: 2,
957
+ borderTopLeftRadius: 2,
958
+ borderTopRightRadius: 2,
959
+ backgroundColor: barBackground,
960
+ }
961
+ : {
962
+ minHeight: '100%' as unknown as number,
963
+ borderTopLeftRadius: 2,
964
+ borderTopRightRadius: 2,
965
+ borderBottomLeftRadius: 0,
966
+ borderBottomRightRadius: 0,
967
+ backgroundColor: barBackground,
968
+ }
365
969
 
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
- )
970
+ const barInner = isLive ? (
971
+ <Animated.View
972
+ style={[barStyle, { transform: [{ scale: liveScale }] }]}
973
+ testID={`velocity-bar-${i}`}
974
+ accessibilityRole="image"
975
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
976
+ />
977
+ ) : (
978
+ <View
979
+ style={barStyle}
980
+ testID={`velocity-bar-${i}`}
981
+ accessibilityRole="image"
982
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
983
+ />
984
+ )
385
985
 
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
- }
986
+ const bar = (
987
+ <View
988
+ key={i}
989
+ style={{
990
+ flex: 1,
991
+ height: '100%',
992
+ justifyContent: 'flex-end',
993
+ position: 'relative',
994
+ }}
995
+ >
996
+ {expanded && showNumbers && (
997
+ <Animated.View
998
+ style={{
999
+ opacity: labelOpacity,
1000
+ alignItems: 'center',
1001
+ position: 'absolute',
1002
+ top: -13,
1003
+ left: 0,
1004
+ right: 0,
1005
+ }}
1006
+ accessibilityElementsHidden
1007
+ >
1008
+ <Text
1009
+ className="text-text-secondary"
1010
+ style={{ fontSize: 8, fontWeight: '600' }}
1011
+ testID={`velocity-label-${i}`}
1012
+ >
1013
+ {formatVelocity(v)}
1014
+ </Text>
1015
+ </Animated.View>
1016
+ )}
1017
+ {barInner}
1018
+ </View>
1019
+ )
400
1020
 
401
- return bar
402
- })}
1021
+ if (onRepPress && expanded) {
1022
+ return (
1023
+ <Pressable
1024
+ key={i}
1025
+ style={{ flex: 1, height: '100%' }}
1026
+ onPress={() => onRepPress(i, v)}
1027
+ accessibilityRole="button"
1028
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
1029
+ testID={`velocity-bar-pressable-${i}`}
1030
+ >
1031
+ {bar}
1032
+ </Pressable>
1033
+ )
1034
+ }
1035
+
1036
+ return bar
1037
+ })}
403
1038
  </View>
404
1039
  {expanded && showInfo && (
405
1040
  <Animated.View