@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
@@ -3,19 +3,28 @@ import { useState } from 'react'
3
3
  import { View, Text, Pressable } from 'react-native'
4
4
  import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
5
5
  import { PlaceholderStrip } from './PlaceholderStrip'
6
- import { WeightBadge } from './WeightBadge'
7
6
  import { PrBadge } from './PrBadge'
8
- import { TempoDisplay } from './TempoDisplay'
9
7
  import { SetRow, type SetRowProps } from './SetRow'
8
+ import { type SetStripSet } from './SetStrip'
9
+ import { SetTableHeader } from './SetTableHeader'
10
+ import { ExerciseCardHeading } from './ExerciseCardHeading'
11
+ import { type ExerciseIndicatorKind } from './ExerciseIndicator'
10
12
  import { roundWeight } from '../../../utils/workout-format'
11
- import { resolveColor } from '../../../theme/resolve-color'
12
-
13
- export type ExerciseCardState = 'collapsed' | 'expanded' | 'upcoming'
13
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
14
14
 
15
15
  export interface ExerciseCardProps {
16
16
  name: string
17
- state: ExerciseCardState
18
- onToggle: () => void
17
+ /**
18
+ * The dimmed, not-yet-reached representation (prescription + previous-best line).
19
+ * Overrides the expand state — an upcoming card never expands.
20
+ */
21
+ upcoming?: boolean
22
+ /** Controlled expand state. Omit to run uncontrolled (see {@link defaultExpanded}). */
23
+ expanded?: boolean
24
+ /** Uncontrolled initial expand state. Default false. Ignored when `expanded` is set. */
25
+ defaultExpanded?: boolean
26
+ /** Notified when the user toggles the card between collapsed and expanded. */
27
+ onExpandedChange?: (expanded: boolean) => void
19
28
  onNavigateDetail?: () => void
20
29
  summary?: {
21
30
  sets: number
@@ -23,27 +32,36 @@ export interface ExerciseCardProps {
23
32
  weight: number
24
33
  unit: 'lbs' | 'kg'
25
34
  }
26
- e1rm?: { value: number; unit: 'lbs' | 'kg' }
27
35
  isPR?: boolean
36
+ /** Collapsed glance: per-set logged velocities (drives the mini strips). */
28
37
  setVelocities?: number[][]
29
38
  /** Optional velocity-zone bands shared across this exercise's sets (WA bands). */
30
39
  velocityZones?: readonly VelocityZoneBandProp[]
40
+ /** Collapsed glance: total planned sets (placeholder strips fill the remainder). */
31
41
  totalPlannedSets?: number
42
+ /** Expanded body: the per-set rows (done / live / todo). */
32
43
  sets?: SetRowProps[]
33
44
  tempo?: [number, number, number, number]
34
45
  prescription?: string
35
46
  previousBest?: string
36
47
  supersetPosition?: 'first' | 'last' | 'middle' | null
37
48
  supersetColor?: string
49
+ /** Expanded header per-set strip override; derived from `sets` when omitted. */
50
+ setStates?: SetStripSet[]
51
+ /** A small PR / issue / info chip in the title line (expanded header). */
52
+ indicator?: ExerciseIndicatorKind
53
+ /** Expanded header strip height in px. Default 8. */
54
+ stripHeight?: number
38
55
  }
39
56
 
40
- /** Column headers; the weight column reflects the card's unit (LBS / KG). */
41
- function buildColumnHeaders(unit: 'lbs' | 'kg'): string[] {
42
- return ['SET', 'PREV', 'REPS', unit.toUpperCase(), 'RPE']
43
- }
57
+ /** The sub-card render props: everything but the expand-control surface, plus a resolved toggle. */
58
+ type CardBodyProps = Omit<
59
+ ExerciseCardProps,
60
+ 'upcoming' | 'expanded' | 'defaultExpanded' | 'onExpandedChange'
61
+ > & { onToggle: () => void }
44
62
 
45
63
  function getSupersetBorderRadius(
46
- position: ExerciseCardProps['supersetPosition'],
64
+ position: ExerciseCardProps['supersetPosition']
47
65
  ): Record<string, number> {
48
66
  switch (position) {
49
67
  case 'first':
@@ -78,7 +96,7 @@ function getSupersetBorderRadius(
78
96
  }
79
97
 
80
98
  function getSupersetMargin(
81
- position: ExerciseCardProps['supersetPosition'],
99
+ position: ExerciseCardProps['supersetPosition']
82
100
  ): Record<string, number> {
83
101
  if (position === 'first' || position === 'middle') {
84
102
  return { marginBottom: 2 }
@@ -88,13 +106,13 @@ function getSupersetMargin(
88
106
 
89
107
  function formatSummary(summary: ExerciseCardProps['summary']): string {
90
108
  if (!summary) return ''
91
- return `${summary.sets}\u00D7${summary.reps} @ ${roundWeight(summary.weight)} ${summary.unit}`
109
+ return `${summary.sets}×${summary.reps} @ ${roundWeight(summary.weight)} ${summary.unit}`
92
110
  }
93
111
 
94
112
  function formatAccessibilityLabel(
95
113
  name: string,
96
114
  summary: ExerciseCardProps['summary'],
97
- prescription: ExerciseCardProps['prescription'],
115
+ prescription: ExerciseCardProps['prescription']
98
116
  ): string {
99
117
  if (summary) return `${name}, ${formatSummary(summary)}`
100
118
  if (prescription) return `${name}, ${prescription}`
@@ -105,13 +123,12 @@ function CollapsedCard({
105
123
  name,
106
124
  onToggle,
107
125
  summary,
108
- e1rm,
109
126
  isPR,
110
127
  setVelocities,
111
128
  velocityZones,
112
129
  totalPlannedSets,
113
130
  supersetPosition,
114
- }: ExerciseCardProps) {
131
+ }: CardBodyProps) {
115
132
  const [pressed, setPressed] = useState(false)
116
133
  const completedSets = setVelocities?.length ?? 0
117
134
  const remaining = Math.max(0, (totalPlannedSets ?? 0) - completedSets)
@@ -137,10 +154,7 @@ function CollapsedCard({
137
154
  ...supersetMargin,
138
155
  }}
139
156
  >
140
- <View
141
- className="flex-row items-center"
142
- testID="exercise-card-header"
143
- >
157
+ <View className="flex-row items-center" testID="exercise-card-header">
144
158
  <Text
145
159
  className="text-text-primary"
146
160
  style={{
@@ -166,15 +180,6 @@ function CollapsedCard({
166
180
  {formatSummary(summary)}
167
181
  </Text>
168
182
  )}
169
- {e1rm && (
170
- <WeightBadge
171
- value={roundWeight(e1rm.value)}
172
- unit={e1rm.unit}
173
- size="sm"
174
- showIcon={false}
175
- testID="exercise-card-e1rm"
176
- />
177
- )}
178
183
  {isPR && (
179
184
  <View style={{ marginLeft: 4 }}>
180
185
  <PrBadge type="e1rm" compact animate={false} />
@@ -183,11 +188,7 @@ function CollapsedCard({
183
188
  </View>
184
189
 
185
190
  {(completedSets > 0 || remaining > 0) && (
186
- <View
187
- className="flex-row"
188
- style={{ marginTop: 6, gap: 4 }}
189
- testID="exercise-card-strips"
190
- >
191
+ <View className="flex-row" style={{ marginTop: 6, gap: 4 }} testID="exercise-card-strips">
191
192
  {setVelocities?.map((velocities, i) => (
192
193
  <VelocityStrip
193
194
  key={i}
@@ -210,22 +211,52 @@ function CollapsedCard({
210
211
  )
211
212
  }
212
213
 
214
+ // --- Unified expanded card ---------------------------------------------------
215
+ // The expanded card is "one object" with its rail heading: the persistent header
216
+ // is the real ExerciseCardHeading, and the revealed body drops PREV and renders
217
+ // one real SetRow per set (muted done/todo, brightened live spotlight).
218
+
219
+ const DARK = getSemanticColors('dark')
220
+ /** The header↔body seam. */
221
+ const BODY_DIVIDER = DARK['border-subtle']
222
+
223
+ /** Project the set rows onto the heading strip's per-set state (done / active / todo). */
224
+ function deriveHeaderSetStates(sets: SetRowProps[]): SetStripSet[] {
225
+ return sets.map((set): SetStripSet => {
226
+ switch (set.state) {
227
+ case 'live':
228
+ return { status: 'active', velocities: set.velocities, planned: set.target.reps }
229
+ case 'done':
230
+ return { status: 'done', velocities: set.velocities }
231
+ case 'todo':
232
+ return { status: 'todo', planned: set.planned ?? set.target.reps }
233
+ }
234
+ })
235
+ }
236
+
213
237
  function ExpandedCard({
214
238
  name,
215
239
  onToggle,
216
240
  summary,
217
- e1rm,
218
241
  isPR,
219
242
  sets,
220
243
  tempo,
244
+ indicator,
245
+ setStates,
246
+ stripHeight = 8,
247
+ velocityZones,
221
248
  supersetPosition,
222
- }: ExerciseCardProps) {
249
+ }: CardBodyProps) {
223
250
  const borderRadius = getSupersetBorderRadius(supersetPosition)
224
251
  const supersetMargin = getSupersetMargin(supersetPosition)
225
252
  // Summary is the card-level authority for the weight column; fall back to the
226
253
  // first set's unit, then lbs. (Mixed per-set units keep the card-level label.)
227
254
  const unit = summary?.unit ?? sets?.[0]?.unit ?? 'lbs'
228
- const columnHeaders = buildColumnHeaders(unit)
255
+ // The rail heading IS the header: its per-set strip comes from an explicit
256
+ // `setStates` when supplied, else it's derived from the set rows. e1RM is dropped
257
+ // entirely; a PR surfaces via the chip.
258
+ const headerStates = setStates ?? (sets ? deriveHeaderSetStates(sets) : [])
259
+ const headerIndicator = indicator ?? (isPR ? 'pr' : undefined)
229
260
 
230
261
  return (
231
262
  <View
@@ -237,26 +268,67 @@ function ExpandedCard({
237
268
  }}
238
269
  testID="exercise-card"
239
270
  >
240
- <Pressable
271
+ <ExerciseCardHeading
272
+ name={name}
273
+ sets={summary?.sets ?? sets?.length ?? 0}
274
+ reps={summary?.reps ?? 0}
275
+ load={summary?.weight ?? 0}
276
+ unit={unit}
277
+ tempo={tempo}
278
+ indicator={headerIndicator}
279
+ setStates={headerStates}
280
+ stripHeight={stripHeight}
241
281
  onPress={onToggle}
242
- accessibilityRole="button"
243
- accessibilityLabel={formatAccessibilityLabel(name, summary, undefined)}
244
- aria-expanded={true}
245
- testID="exercise-card-header"
282
+ testID="exercise-card-heading"
283
+ />
284
+
285
+ <View
286
+ style={{ borderTopWidth: 1, borderTopColor: BODY_DIVIDER, paddingBottom: 6 }}
287
+ testID="exercise-card-body"
288
+ >
289
+ <SetTableHeader unit={unit} showPrevious={false} testID="exercise-card-column-headers" />
290
+
291
+ {sets && (
292
+ <View testID="exercise-card-sets">
293
+ {sets.map((set, i) => (
294
+ <SetRow key={i} {...set} velocityZones={set.velocityZones ?? velocityZones} />
295
+ ))}
296
+ </View>
297
+ )}
298
+ </View>
299
+ </View>
300
+ )
301
+ }
302
+
303
+ function UpcomingCard({
304
+ name,
305
+ prescription,
306
+ previousBest,
307
+ supersetPosition,
308
+ onToggle,
309
+ }: CardBodyProps) {
310
+ const borderRadius = getSupersetBorderRadius(supersetPosition)
311
+ const supersetMargin = getSupersetMargin(supersetPosition)
312
+
313
+ return (
314
+ <Pressable onPress={onToggle} accessibilityRole="button">
315
+ <View
316
+ style={{
317
+ opacity: 0.6,
318
+ padding: 12,
319
+ paddingHorizontal: 14,
320
+ ...borderRadius,
321
+ ...supersetMargin,
322
+ }}
323
+ accessibilityLabel={formatAccessibilityLabel(name, undefined, prescription)}
324
+ testID="exercise-card"
246
325
  >
247
- <View
248
- className="flex-row items-center"
249
- style={{
250
- padding: 12,
251
- paddingHorizontal: 14,
252
- paddingBottom: 10,
253
- borderBottomWidth: 1,
254
- borderBottomColor: resolveColor('border-default'),
255
- }}
256
- >
326
+ <View className="flex-row items-center">
327
+ {/* Name never truncates (no numberOfLines); prescription + previousBest ellipsize first. */}
257
328
  <Text
258
329
  className="text-text-primary"
259
330
  style={{
331
+ flexShrink: 0,
260
332
  fontSize: 14,
261
333
  fontFamily: '"Space Grotesk", sans-serif',
262
334
  fontWeight: '700',
@@ -265,167 +337,73 @@ function ExpandedCard({
265
337
  >
266
338
  {name}
267
339
  </Text>
268
- <View className="flex-1" />
269
- {summary && (
340
+ {prescription && (
270
341
  <Text
271
342
  className="text-text-secondary"
343
+ numberOfLines={1}
272
344
  style={{
345
+ flexShrink: 1,
273
346
  fontSize: 12,
274
347
  fontFamily: 'Inter, sans-serif',
275
- marginRight: 8,
348
+ marginLeft: 8,
276
349
  }}
277
- testID="exercise-card-summary"
350
+ testID="exercise-card-prescription"
278
351
  >
279
- {formatSummary(summary)}
352
+ {prescription}
280
353
  </Text>
281
354
  )}
282
- {e1rm && (
283
- <WeightBadge
284
- value={roundWeight(e1rm.value)}
285
- unit={e1rm.unit}
286
- size="sm"
287
- showIcon={false}
288
- testID="exercise-card-e1rm"
289
- />
290
- )}
291
- {isPR && (
292
- <View style={{ marginLeft: 4 }}>
293
- <PrBadge type="e1rm" compact animate={false} />
294
- </View>
295
- )}
296
- </View>
297
- </Pressable>
298
-
299
- {tempo && (
300
- <View style={{ marginTop: 8, paddingHorizontal: 14 }} testID="exercise-card-tempo">
301
- <TempoDisplay tempo={tempo} size="sm" />
302
- </View>
303
- )}
304
-
305
- <View
306
- className="flex-row"
307
- style={{
308
- paddingVertical: 8,
309
- paddingHorizontal: 8,
310
- paddingBottom: 4,
311
- }}
312
- testID="exercise-card-column-headers"
313
- >
314
- {columnHeaders.map((header, i) => {
315
- const widths = [36, undefined, 44, 56, 36]
316
- const width = widths[i]
317
- const isFlex = width === undefined
318
-
319
- return (
320
- <View
321
- key={header}
355
+ <View className="flex-1" style={{ minWidth: 8 }} />
356
+ {previousBest && (
357
+ <Text
358
+ className="text-text-tertiary"
359
+ numberOfLines={1}
322
360
  style={{
323
- ...(isFlex ? {} : { width }),
324
- alignItems: 'center',
325
- justifyContent: 'center',
326
- gap: 8,
361
+ flexShrink: 1,
362
+ fontSize: 11,
363
+ fontFamily: 'Inter, sans-serif',
327
364
  }}
328
- {...(isFlex ? { className: 'flex-1' } : {})}
365
+ testID="exercise-card-previous-best"
329
366
  >
330
- <Text
331
- className="text-text-tertiary"
332
- style={{
333
- fontSize: 10,
334
- fontWeight: '600',
335
- fontFamily: 'Inter, sans-serif',
336
- textTransform: 'uppercase',
337
- letterSpacing: 0.8,
338
- }}
339
- >
340
- {header}
341
- </Text>
342
- </View>
343
- )
344
- })}
345
- </View>
346
-
347
- {sets && (
348
- <View testID="exercise-card-sets">
349
- {sets.map((setProps, i) => (
350
- <SetRow key={i} {...setProps} />
351
- ))}
367
+ {previousBest}
368
+ </Text>
369
+ )}
352
370
  </View>
353
- )}
354
- </View>
371
+ </View>
372
+ </Pressable>
355
373
  )
356
374
  }
357
375
 
358
- function UpcomingCard({
359
- name,
360
- prescription,
361
- previousBest,
362
- supersetPosition,
376
+ /**
377
+ * The data-contract exercise card, in three representations:
378
+ * - `upcoming` — a dimmed, not-yet-reached row (prescription + previous best).
379
+ * - collapsed — a glance: name + summary + per-set velocity strips.
380
+ * - expanded — the unified card: the real {@link ExerciseCardHeading} header over
381
+ * the SET · REPS · LBS · RPE body (one {@link SetRow} per set).
382
+ *
383
+ * Expand is controlled (`expanded` + `onExpandedChange`) or uncontrolled
384
+ * (`defaultExpanded`, internal state). `upcoming` overrides expand.
385
+ */
386
+ export function ExerciseCard({
387
+ upcoming,
388
+ expanded,
389
+ defaultExpanded,
390
+ onExpandedChange,
391
+ ...rest
363
392
  }: ExerciseCardProps) {
364
- const borderRadius = getSupersetBorderRadius(supersetPosition)
365
- const supersetMargin = getSupersetMargin(supersetPosition)
366
-
367
- return (
368
- <View
369
- style={{
370
- opacity: 0.6,
371
- padding: 12,
372
- paddingHorizontal: 14,
373
- ...borderRadius,
374
- ...supersetMargin,
375
- }}
376
- accessibilityLabel={formatAccessibilityLabel(name, undefined, prescription)}
377
- testID="exercise-card"
378
- >
379
- <View className="flex-row items-center">
380
- <Text
381
- className="text-text-primary"
382
- style={{
383
- fontSize: 14,
384
- fontFamily: '"Space Grotesk", sans-serif',
385
- fontWeight: '700',
386
- }}
387
- testID="exercise-card-name"
388
- >
389
- {name}
390
- </Text>
391
- {prescription && (
392
- <Text
393
- className="text-text-secondary"
394
- style={{
395
- fontSize: 12,
396
- fontFamily: 'Inter, sans-serif',
397
- marginLeft: 8,
398
- }}
399
- testID="exercise-card-prescription"
400
- >
401
- {prescription}
402
- </Text>
403
- )}
404
- <View className="flex-1" />
405
- {previousBest && (
406
- <Text
407
- className="text-text-tertiary"
408
- style={{
409
- fontSize: 11,
410
- fontFamily: 'Inter, sans-serif',
411
- }}
412
- testID="exercise-card-previous-best"
413
- >
414
- {previousBest}
415
- </Text>
416
- )}
417
- </View>
418
- </View>
419
- )
420
- }
393
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpanded ?? false)
394
+ const isControlled = expanded !== undefined
395
+ const isExpanded = isControlled ? expanded : internalExpanded
421
396
 
422
- export function ExerciseCard(props: ExerciseCardProps) {
423
- switch (props.state) {
424
- case 'collapsed':
425
- return <CollapsedCard {...props} />
426
- case 'expanded':
427
- return <ExpandedCard {...props} />
428
- case 'upcoming':
429
- return <UpcomingCard {...props} />
397
+ const onToggle = () => {
398
+ const next = !isExpanded
399
+ onExpandedChange?.(next)
400
+ if (!isControlled) setInternalExpanded(next)
430
401
  }
402
+
403
+ if (upcoming) return <UpcomingCard {...rest} onToggle={onToggle} />
404
+ return isExpanded ? (
405
+ <ExpandedCard {...rest} onToggle={onToggle} />
406
+ ) : (
407
+ <CollapsedCard {...rest} onToggle={onToggle} />
408
+ )
431
409
  }
@@ -0,0 +1,99 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { ExerciseCardHeading } from './ExerciseCardHeading'
4
+ import type { SetStripSet } from './SetStrip'
5
+
6
+ /**
7
+ * `ExerciseCardHeading` — the complete, standalone session-rail heading: an
8
+ * `ExerciseHeading` info block over its per-set `SetStrip`. This is the unit the rail
9
+ * lists; it is independent of `ExerciseCard` (whose `state="rail"` delegates here).
10
+ * An empty `setStates` renders no strip.
11
+ */
12
+ const meta: Meta<typeof ExerciseCardHeading> = {
13
+ title: 'Workout/ExerciseCardHeading',
14
+ component: ExerciseCardHeading,
15
+ tags: ['autodocs'],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ '**Molecule.** The complete standalone session-rail heading. Composes ' +
21
+ '[ExerciseHeading](?path=/docs/workout-exerciseheading--docs) + ' +
22
+ '[SetStrip](?path=/docs/workout-setstrip--docs). ' +
23
+ 'Used-by ↑ [SessionRail](?path=/docs/shell-sessionrail--docs); ' +
24
+ '[ExerciseCard](?path=/docs/workout-exercisecard--docs) `state="rail"` delegates here.',
25
+ },
26
+ },
27
+ },
28
+ argTypes: {
29
+ unit: { control: 'select', options: ['lbs', 'kg'] },
30
+ indicator: {
31
+ control: 'select',
32
+ options: [undefined, 'imbalance', 'overshoot', 'velocity-loss', 'missed-reps', 'pr', 'info'],
33
+ },
34
+ stripHeight: { control: { type: 'range', min: 2, max: 16, step: 1 } },
35
+ dimmed: { control: 'boolean' },
36
+ onPress: { action: 'press' },
37
+ },
38
+ decorators: [
39
+ (Story) => (
40
+ <View style={{ width: 246, backgroundColor: '#131313' }}>
41
+ <Story />
42
+ </View>
43
+ ),
44
+ ],
45
+ }
46
+
47
+ export default meta
48
+ type Story = StoryObj<typeof ExerciseCardHeading>
49
+
50
+ const decay = (n: number, start: number, span = 0.4): number[] =>
51
+ Array.from({ length: n }, (_, r) => +(start - (span * r) / Math.max(1, n - 1)).toFixed(3))
52
+
53
+ const inProgress: SetStripSet[] = [
54
+ { status: 'done', velocities: decay(10, 0.72) },
55
+ { status: 'active', velocities: decay(5, 0.62), planned: 10 },
56
+ { status: 'todo', planned: 10 },
57
+ ]
58
+
59
+ export const Default: Story = {
60
+ args: {
61
+ name: 'Cable Chest Press',
62
+ sets: 3,
63
+ reps: 10,
64
+ load: 90,
65
+ unit: 'lbs',
66
+ tempo: [2, 1, 2, 0],
67
+ indicator: 'info',
68
+ setStates: inProgress,
69
+ stripHeight: 8,
70
+ },
71
+ }
72
+
73
+ export const Dimmed: Story = {
74
+ args: {
75
+ ...Default.args,
76
+ dimmed: true,
77
+ setStates: [1, 2, 3].map((): SetStripSet => ({ status: 'todo', planned: 18 })),
78
+ },
79
+ parameters: {
80
+ docs: { description: { story: 'A not-yet-reached exercise — the whole heading dims.' } },
81
+ },
82
+ }
83
+
84
+ export const WithoutStrip: Story = {
85
+ args: {
86
+ name: 'Standing Calf Raise',
87
+ sets: 5,
88
+ reps: 20,
89
+ load: 25,
90
+ unit: 'lbs',
91
+ tempo: [2, 1, 2, 0],
92
+ setStates: [],
93
+ },
94
+ parameters: {
95
+ docs: {
96
+ description: { story: 'An empty `setStates` list renders the heading with no strip.' },
97
+ },
98
+ },
99
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { ExerciseCardHeading } from './ExerciseCardHeading'
5
+ import type { SetStripSet } from './SetStrip'
6
+
7
+ const setStates: SetStripSet[] = [
8
+ { status: 'done', velocities: [1, 0.9] },
9
+ { status: 'active', velocities: [0.6], planned: 8 },
10
+ ]
11
+
12
+ const baseProps = {
13
+ name: 'Cable Chest Press',
14
+ sets: 3,
15
+ reps: 10,
16
+ load: 90,
17
+ unit: 'lbs' as const,
18
+ tempo: [2, 1, 2, 0] as [number, number, number, number],
19
+ indicator: 'info' as const,
20
+ setStates,
21
+ }
22
+
23
+ describe('ExerciseCardHeading', () => {
24
+ it('composes the heading info block and the per-set strip', () => {
25
+ render(<ExerciseCardHeading {...baseProps} />)
26
+ expect(screen.getByTestId('exercise-card')).toBeInTheDocument()
27
+ expect(screen.getByTestId('exercise-heading')).toBeInTheDocument()
28
+ expect(screen.getByTestId('exercise-card-strip')).toBeInTheDocument()
29
+ expect(screen.getByTestId('set-strip')).toBeInTheDocument()
30
+ })
31
+
32
+ it('renders the name and the sets/reps/load line', () => {
33
+ render(<ExerciseCardHeading {...baseProps} />)
34
+ expect(screen.getByTestId('exercise-card-name')).toHaveTextContent('Cable Chest Press')
35
+ const summary = screen.getByTestId('exercise-card-summary')
36
+ expect(summary).toHaveTextContent('3')
37
+ expect(summary).toHaveTextContent('10')
38
+ expect(summary).toHaveTextContent('90')
39
+ })
40
+
41
+ it('renders no strip when setStates is empty', () => {
42
+ render(<ExerciseCardHeading {...baseProps} setStates={[]} />)
43
+ expect(screen.getByTestId('exercise-heading')).toBeInTheDocument()
44
+ expect(screen.queryByTestId('exercise-card-strip')).not.toBeInTheDocument()
45
+ expect(screen.queryByTestId('set-strip')).not.toBeInTheDocument()
46
+ })
47
+
48
+ it('dims the whole heading when marked dimmed', () => {
49
+ render(<ExerciseCardHeading {...baseProps} dimmed />)
50
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({ opacity: 0.55 })
51
+ })
52
+
53
+ it('is full opacity by default', () => {
54
+ render(<ExerciseCardHeading {...baseProps} />)
55
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({ opacity: 1 })
56
+ })
57
+
58
+ it('fires onPress when the row is pressed', () => {
59
+ const onPress = vi.fn()
60
+ render(<ExerciseCardHeading {...baseProps} onPress={onPress} />)
61
+ fireEvent.click(screen.getByTestId('exercise-card-header'))
62
+ expect(onPress).toHaveBeenCalledOnce()
63
+ })
64
+
65
+ describe('accessibility', () => {
66
+ it('has no accessibility violations', async () => {
67
+ const { container } = render(<ExerciseCardHeading {...baseProps} onPress={vi.fn()} />)
68
+ const results = await axe(container)
69
+ expect(results).toHaveNoViolations()
70
+ })
71
+ })
72
+ })