@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
@@ -0,0 +1,43 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View } from 'react-native'
3
+ import { ExerciseHeading, type ExerciseHeadingProps } from './ExerciseHeading'
4
+ import { SetStrip, type SetStripSet } from './SetStrip'
5
+
6
+ export interface ExerciseCardHeadingProps extends ExerciseHeadingProps {
7
+ /** Per-set performance data driving the strip; an empty list renders no strip. */
8
+ setStates: SetStripSet[]
9
+ /** Strip height in px. Default 8. */
10
+ stripHeight?: number
11
+ /** Root testID. Default "exercise-card"; override when nested inside another card. */
12
+ testID?: string
13
+ }
14
+
15
+ /**
16
+ * The complete, standalone session-rail heading: an {@link ExerciseHeading} info
17
+ * block over its per-set {@link SetStrip}. The unit the rail lists — self-contained
18
+ * and independent of {@link ExerciseCard} (whose `rail` state delegates here).
19
+ * e1RM is intentionally dropped (a planning / live-panel concern). Dimming lives on
20
+ * the outer wrapper so the strip dims with the heading.
21
+ */
22
+ export function ExerciseCardHeading({
23
+ setStates,
24
+ stripHeight = 8,
25
+ dimmed,
26
+ testID = 'exercise-card',
27
+ ...heading
28
+ }: ExerciseCardHeadingProps) {
29
+ return (
30
+ <View
31
+ style={{ paddingVertical: 9, paddingHorizontal: 12, opacity: dimmed ? 0.55 : 1 }}
32
+ testID={testID}
33
+ >
34
+ <ExerciseHeading {...heading} />
35
+
36
+ {setStates.length > 0 && (
37
+ <View style={{ marginTop: 7 }} testID="exercise-card-strip">
38
+ <SetStrip sets={setStates} height={stripHeight} />
39
+ </View>
40
+ )}
41
+ </View>
42
+ )
43
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * SHEET · Unified exercise card (page 2) — the LOCKED rail-expansion, hardened onto the
3
+ * REAL {@link ExerciseCard}. The collapsed rail item and the expanded card are ONE object: the
4
+ * persistent header is the real {@link ExerciseCardHeading}, and expanding reveals the body on the
5
+ * SAME surface — PREV dropped, done + upcoming sets share ONE muted treatment, the ACTIVE set
6
+ * stands out by brightness. Every per-row strip is the REAL {@link VelocityStrip} via its `set`
7
+ * descriptor: `mini` (flat 3px) for done/upcoming, and the `compact` velocity-height variant as
8
+ * the active-set SPOTLIGHT (option B, locked). No hand-built prototype strip remains.
9
+ */
10
+ import type { Meta, StoryObj } from '@storybook/react-vite'
11
+ import { View, Text } from 'react-native'
12
+ import { ExerciseCard } from './ExerciseCard'
13
+ import { ExerciseCardHeading } from './ExerciseCardHeading'
14
+ import { VelocityStrip } from './VelocityStrip'
15
+ import { type SetRowProps } from './SetRow'
16
+ import { type SetStripSet } from './SetStrip'
17
+ import { INSET, BORDER_SUBTLE, T_PRIMARY, T_SECONDARY, Page, monoTag } from './setHeadingKit'
18
+
19
+ const INTER = 'Inter, sans-serif'
20
+
21
+ /** Per-rep mean velocity that DECAYS across a set (fast → slow). */
22
+ const decay = (n: number, start: number, span = 0.5) =>
23
+ Array.from({ length: n }, (_, r) => +(start - (span * r) / Math.max(1, n - 1)).toFixed(3))
24
+
25
+ // The exercise: a flat straight set (Cable Chest Press, 3 × 10 @ 90 lb).
26
+ const DONE = decay(10, 1.05)
27
+ const ACTIVE = decay(5, 0.95, 0.3)
28
+
29
+ // One data source drives both representations. Set 1 done · set 2 LIVE · set 3 upcoming.
30
+ const SETS: SetRowProps[] = [
31
+ { state: 'done', setNumber: 1, unit: 'lbs', reps: 10, weight: 90, rpe: 7.5, velocities: DONE },
32
+ {
33
+ state: 'live',
34
+ setNumber: 2,
35
+ unit: 'lbs',
36
+ target: { reps: 10, weight: 90 },
37
+ reps: 5,
38
+ weight: 90,
39
+ velocities: ACTIVE,
40
+ },
41
+ { state: 'todo', setNumber: 3, unit: 'lbs', target: { reps: 10, weight: 90 } },
42
+ ]
43
+
44
+ // The header's per-set strip: the same lifecycle the card derives internally, built here
45
+ // so the collapsed rail item (ExerciseCardHeading) reads from the same source as the body.
46
+ const HEADER_STATES: SetStripSet[] = [
47
+ { status: 'done', velocities: DONE },
48
+ { status: 'active', velocities: ACTIVE, planned: 10 },
49
+ { status: 'todo', planned: 10 },
50
+ ]
51
+
52
+ const CARD_PROPS = {
53
+ name: 'Cable Chest Press',
54
+ summary: { sets: 3, reps: 10, weight: 90, unit: 'lbs' as const },
55
+ tempo: [2, 1, 2, 0] as [number, number, number, number],
56
+ indicator: 'info' as const,
57
+ sets: SETS,
58
+ }
59
+
60
+ function Surface({ children, width = 260 }: { children: React.ReactNode; width?: number }) {
61
+ return (
62
+ <View style={{ width, backgroundColor: INSET, borderRadius: 8, overflow: 'hidden' }}>
63
+ {children}
64
+ </View>
65
+ )
66
+ }
67
+
68
+ const meta: Meta = {
69
+ title: 'Lab/Explorations/Unified Exercise Card',
70
+ parameters: { layout: 'fullscreen' },
71
+ }
72
+ export default meta
73
+ type Story = StoryObj
74
+
75
+ export const Unified: Story = {
76
+ name: 'One object · rail header → expanded body (real ExerciseCard)',
77
+ render: () => (
78
+ <Page
79
+ title="Unified exercise card — the rail heading IS the header"
80
+ note="The LOCKED rail-expansion, hardened onto the real ExerciseCard. Collapsed, it's the rail item (the real ExerciseCardHeading); expanded, the SAME surface reveals the table body — PREV dropped, done + upcoming sets share ONE muted treatment, the ACTIVE set stands out by brightness. Every per-row strip is the real VelocityStrip: `mini` for done/upcoming, the `compact` velocity-height variant as the active-set spotlight (B, locked). No hand-built prototype strip remains."
81
+ >
82
+ <View style={{ flexDirection: 'row', gap: 40, alignItems: 'flex-start', flexWrap: 'wrap' }}>
83
+ <View style={{ gap: 8 }}>
84
+ <Text style={{ ...monoTag, color: T_SECONDARY }}>
85
+ collapsed · rail item (ExerciseCardHeading)
86
+ </Text>
87
+ <Surface>
88
+ <ExerciseCardHeading
89
+ name={CARD_PROPS.name}
90
+ sets={3}
91
+ reps={10}
92
+ load={90}
93
+ unit="lbs"
94
+ tempo={CARD_PROPS.tempo}
95
+ indicator={CARD_PROPS.indicator}
96
+ setStates={HEADER_STATES}
97
+ />
98
+ </Surface>
99
+ </View>
100
+
101
+ <View style={{ gap: 8 }}>
102
+ <Text style={{ ...monoTag, color: T_SECONDARY }}>
103
+ expanded · same header + revealed body
104
+ </Text>
105
+ <Surface>
106
+ <ExerciseCard {...CARD_PROPS} defaultExpanded />
107
+ </Surface>
108
+ </View>
109
+ </View>
110
+
111
+ <View style={{ height: 1, backgroundColor: BORDER_SUBTLE, marginVertical: 8 }} />
112
+
113
+ <View style={{ gap: 10, maxWidth: 320 }}>
114
+ <Text style={{ ...monoTag, color: T_SECONDARY }}>
115
+ the locked active-set spotlight · VelocityStrip variant=&quot;expanded&quot; (bare)
116
+ </Text>
117
+ <View style={{ backgroundColor: INSET, borderRadius: 8, padding: 12 }}>
118
+ <VelocityStrip
119
+ variant="expanded"
120
+ showNumbers={false}
121
+ showInfo={false}
122
+ height={24}
123
+ scale="fixed"
124
+ set={{ type: 'straight', velocities: ACTIVE, planned: 10 }}
125
+ />
126
+ </View>
127
+ <Text style={{ fontFamily: INTER, fontSize: 12.5, color: T_SECONDARY, lineHeight: 19 }}>
128
+ A small fixed-height (~24px) velocity-HEIGHT strip: bar height ∝ velocity against a fixed
129
+ 1.15 m/s ceiling, planned reps as grey stubs, set-type aware — none of the full
130
+ variant&apos;s labels, info row, or mount animation. It reads as the same object as the
131
+ flat `mini` strips above it, just taller and brighter to mark the live set.
132
+ </Text>
133
+ </View>
134
+
135
+ <Text
136
+ style={{
137
+ fontFamily: INTER,
138
+ fontSize: 12.5,
139
+ color: T_PRIMARY,
140
+ lineHeight: 19,
141
+ maxWidth: 780,
142
+ marginTop: 6,
143
+ }}
144
+ >
145
+ The header is the same molecule collapsed and expanded — the card reads as one object, the
146
+ body flowing out of the rail row rather than a separate panel. The expanded card is the real
147
+ ExerciseCard; its header per-set strip is derived from the same rows the body renders.
148
+ </Text>
149
+ </Page>
150
+ ),
151
+ }
@@ -31,13 +31,15 @@ const meso: MesoStatusCardProps = {
31
31
 
32
32
  function historySets(weight: number, topRpe: number): SetRowProps[] {
33
33
  return [1, 2, 3, 4].map((setNumber) => ({
34
- mode: 'history' as const,
34
+ state: 'done' as const,
35
35
  setNumber,
36
- previous: { reps: 8, weight: weight - 5 },
37
36
  reps: 8,
38
37
  weight,
39
38
  rpe: topRpe - (4 - setNumber) * 0.5,
40
39
  unit: 'lbs' as const,
40
+ velocities: Array.from({ length: 8 }, (_, i) =>
41
+ Number((0.72 - (setNumber - 1) * 0.04 - i * 0.03).toFixed(2))
42
+ ),
41
43
  }))
42
44
  }
43
45
 
@@ -188,7 +190,7 @@ const vbt: ExerciseVbt = {
188
190
  }
189
191
 
190
192
  const meta: Meta<typeof ExerciseDetailPage> = {
191
- title: 'Custom/Workout/ExerciseDetailPage',
193
+ title: 'Pages/Exercise Detail',
192
194
  component: ExerciseDetailPage,
193
195
  parameters: { layout: 'fullscreen' },
194
196
  tags: ['autodocs'],
@@ -16,8 +16,8 @@ const entry = (id: string, weight: number, e1rm: number, isPR = false): Exercise
16
16
  e1rm: { value: e1rm, unit: 'lbs' },
17
17
  isPR,
18
18
  sets: [
19
- { mode: 'history', setNumber: 1, reps: 8, weight, rpe: 8, unit: 'lbs' },
20
- { mode: 'history', setNumber: 2, reps: 8, weight, rpe: 8.5, unit: 'lbs' },
19
+ { state: 'done', setNumber: 1, unit: 'lbs', reps: 8, weight, rpe: 8, velocities: [] },
20
+ { state: 'done', setNumber: 2, unit: 'lbs', reps: 8, weight, rpe: 8.5, velocities: [] },
21
21
  ],
22
22
  })
23
23
 
@@ -67,11 +67,11 @@ describe('deriveExerciseStats', () => {
67
67
  describe('toExerciseCardProps', () => {
68
68
  it('collapses without sets and expands with them', () => {
69
69
  const collapsed = toExerciseCardProps(history[0], false, () => {})
70
- expect(collapsed.state).toBe('collapsed')
70
+ expect(collapsed.expanded).toBe(false)
71
71
  expect(collapsed.sets).toBeUndefined()
72
72
 
73
73
  const expanded = toExerciseCardProps(history[0], true, () => {})
74
- expect(expanded.state).toBe('expanded')
74
+ expect(expanded.expanded).toBe(true)
75
75
  expect(expanded.sets).toHaveLength(2)
76
76
  expect(expanded.name).toBe('Session a')
77
77
  })
@@ -65,8 +65,8 @@ export interface ExerciseDetailEntry {
65
65
  title: string
66
66
  /** Collapsed summary (sets × reps @ weight). */
67
67
  summary?: ExerciseCardProps['summary']
68
- /** Estimated one-rep max badge. */
69
- e1rm?: ExerciseCardProps['e1rm']
68
+ /** Estimated one-rep max, feeding the page's best-e1RM stat. */
69
+ e1rm?: { value: number; unit: 'lbs' | 'kg' }
70
70
  /** Flags a personal-record entry. */
71
71
  isPR?: boolean
72
72
  /** Prescribed tempo, revealed when expanded. */
@@ -162,10 +162,9 @@ export function toExerciseCardProps(
162
162
  ): ExerciseCardProps {
163
163
  return {
164
164
  name: entry.title,
165
- state: expanded ? 'expanded' : 'collapsed',
166
- onToggle,
165
+ expanded,
166
+ onExpandedChange: onToggle,
167
167
  summary: entry.summary,
168
- e1rm: entry.e1rm,
169
168
  isPR: entry.isPR,
170
169
  tempo: entry.tempo,
171
170
  sets: expanded ? entry.sets : undefined,
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { ExerciseHeading } from './ExerciseHeading'
4
+
5
+ /**
6
+ * `ExerciseHeading` — the exercise-heading info block (no strip): the name +
7
+ * `ExerciseIndicator` title row over a tight `SetsRepsLoad` prescription line beside
8
+ * the real `TempoDisplay` (`showLabel={false}`). The name row is the sole press
9
+ * target; TempoDisplay is a sibling (it is itself a Pressable).
10
+ */
11
+ const meta: Meta<typeof ExerciseHeading> = {
12
+ title: 'Workout/ExerciseHeading',
13
+ component: ExerciseHeading,
14
+ tags: ['autodocs'],
15
+ parameters: {
16
+ docs: {
17
+ description: {
18
+ component:
19
+ '**Molecule.** The exercise-heading info block (no strip). Composes ' +
20
+ '[ExerciseIndicator](?path=/docs/workout-exerciseindicator--docs) + ' +
21
+ '[SetsRepsLoad](?path=/docs/workout-setsrepsload--docs) + ' +
22
+ '[TempoDisplay](?path=/docs/workout-tempodisplay--docs). ' +
23
+ 'Used-by ↑ [ExerciseCardHeading](?path=/docs/workout-exercisecardheading--docs).',
24
+ },
25
+ },
26
+ },
27
+ argTypes: {
28
+ unit: { control: 'select', options: ['lbs', 'kg'] },
29
+ indicator: {
30
+ control: 'select',
31
+ options: [undefined, 'imbalance', 'overshoot', 'velocity-loss', 'missed-reps', 'pr', 'info'],
32
+ },
33
+ dimmed: { control: 'boolean' },
34
+ onPress: { action: 'press' },
35
+ },
36
+ decorators: [
37
+ (Story) => (
38
+ <View style={{ width: 246, padding: 16, backgroundColor: '#131313' }}>
39
+ <Story />
40
+ </View>
41
+ ),
42
+ ],
43
+ }
44
+
45
+ export default meta
46
+ type Story = StoryObj<typeof ExerciseHeading>
47
+
48
+ export const Default: Story = {
49
+ args: {
50
+ name: 'Seated Cable Row',
51
+ sets: 5,
52
+ reps: 8,
53
+ load: 145,
54
+ unit: 'lbs',
55
+ tempo: [3, 1, 2, 0],
56
+ indicator: 'pr',
57
+ },
58
+ }
59
+
60
+ export const NoTempoNoIndicator: Story = {
61
+ args: { name: 'Incline DB Press', sets: 3, reps: 8, load: 70, unit: 'lbs' },
62
+ }
63
+
64
+ export const RepRange: Story = {
65
+ args: {
66
+ name: 'Face Pull',
67
+ sets: 3,
68
+ reps: '15-20',
69
+ load: 40,
70
+ unit: 'lbs',
71
+ tempo: [2, 1, 2, 0],
72
+ indicator: 'info',
73
+ },
74
+ }
75
+
76
+ export const Dimmed: Story = {
77
+ args: { ...Default.args, dimmed: true },
78
+ parameters: {
79
+ docs: { description: { story: 'Dimmed as a not-yet-reached exercise (standalone use).' } },
80
+ },
81
+ }
@@ -0,0 +1,77 @@
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 { ExerciseHeading } from './ExerciseHeading'
5
+
6
+ const baseProps = {
7
+ name: 'Seated Cable Row',
8
+ sets: 5,
9
+ reps: 8,
10
+ load: 145,
11
+ unit: 'lbs' as const,
12
+ tempo: [3, 1, 2, 0] as [number, number, number, number],
13
+ indicator: 'pr' as const,
14
+ }
15
+
16
+ describe('ExerciseHeading', () => {
17
+ it('renders the exercise name', () => {
18
+ render(<ExerciseHeading {...baseProps} />)
19
+ expect(screen.getByTestId('exercise-card-name')).toHaveTextContent('Seated Cable Row')
20
+ })
21
+
22
+ it('renders the sets/reps/load line with separators', () => {
23
+ render(<ExerciseHeading {...baseProps} />)
24
+ const summary = screen.getByTestId('exercise-card-summary')
25
+ expect(summary).toHaveTextContent('5')
26
+ expect(summary).toHaveTextContent('8')
27
+ expect(summary).toHaveTextContent('145')
28
+ expect(summary).toHaveTextContent('×')
29
+ expect(summary).toHaveTextContent('@')
30
+ })
31
+
32
+ it('renders the indicator chip when provided', () => {
33
+ render(<ExerciseHeading {...baseProps} />)
34
+ expect(screen.getByLabelText('Personal record')).toBeInTheDocument()
35
+ })
36
+
37
+ it('omits the indicator chip when absent', () => {
38
+ render(<ExerciseHeading {...baseProps} indicator={undefined} />)
39
+ expect(screen.queryByTestId('exercise-indicator')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('renders the TempoDisplay without its TEMPO caption when tempo is given', () => {
43
+ render(<ExerciseHeading {...baseProps} />)
44
+ expect(screen.getByTestId('tempo-display')).toBeInTheDocument()
45
+ expect(screen.queryByText('TEMPO')).not.toBeInTheDocument()
46
+ })
47
+
48
+ it('omits the TempoDisplay when no tempo', () => {
49
+ render(<ExerciseHeading {...baseProps} tempo={undefined} />)
50
+ expect(screen.queryByTestId('tempo-display')).not.toBeInTheDocument()
51
+ })
52
+
53
+ it('fires onPress when the name row is pressed', () => {
54
+ const onPress = vi.fn()
55
+ render(<ExerciseHeading {...baseProps} onPress={onPress} />)
56
+ fireEvent.click(screen.getByTestId('exercise-card-header'))
57
+ expect(onPress).toHaveBeenCalledOnce()
58
+ })
59
+
60
+ it('dims the block when marked dimmed', () => {
61
+ render(<ExerciseHeading {...baseProps} dimmed />)
62
+ expect(screen.getByTestId('exercise-heading')).toHaveStyle({ opacity: 0.55 })
63
+ })
64
+
65
+ it('is full opacity by default', () => {
66
+ render(<ExerciseHeading {...baseProps} />)
67
+ expect(screen.getByTestId('exercise-heading')).toHaveStyle({ opacity: 1 })
68
+ })
69
+
70
+ describe('accessibility', () => {
71
+ it('has no accessibility violations', async () => {
72
+ const { container } = render(<ExerciseHeading {...baseProps} onPress={vi.fn()} />)
73
+ const results = await axe(container)
74
+ expect(results).toHaveNoViolations()
75
+ })
76
+ })
77
+ })
@@ -0,0 +1,85 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, Pressable } from 'react-native'
3
+ import { roundWeight } from '../../../utils/workout-format'
4
+ import { SetsRepsLoad } from './SetsRepsLoad'
5
+ import { TempoDisplay } from './TempoDisplay'
6
+ import { ExerciseIndicator, type ExerciseIndicatorKind } from './ExerciseIndicator'
7
+
8
+ export interface ExerciseHeadingProps {
9
+ name: string
10
+ /** Prescription line values (sets × reps @ load). */
11
+ sets: number
12
+ reps: number | string
13
+ load: number
14
+ unit?: 'lbs' | 'kg'
15
+ /** Tempo tuple [eccentric, pauseBottom, concentric, pauseTop]; hidden when absent. */
16
+ tempo?: [number, number, number, number]
17
+ /** Small PR / issue / info chip in the title row. */
18
+ indicator?: ExerciseIndicatorKind
19
+ /** Dim the block as a not-yet-reached exercise (standalone use). */
20
+ dimmed?: boolean
21
+ /** Press target for the name row (expand / select). */
22
+ onPress?: () => void
23
+ }
24
+
25
+ function headingLabel(
26
+ name: string,
27
+ sets: number,
28
+ reps: number | string,
29
+ load: number,
30
+ unit: string
31
+ ) {
32
+ return `${name}, ${sets}×${reps} @ ${roundWeight(load)} ${unit}`
33
+ }
34
+
35
+ /**
36
+ * The exercise-heading info block (no strip): the name + {@link ExerciseIndicator}
37
+ * title row over a tight {@link SetsRepsLoad} prescription line beside the real
38
+ * {@link TempoDisplay} (`showLabel={false}`). The name row is the sole press
39
+ * target — TempoDisplay is a sibling, never a descendant, since it is itself a
40
+ * Pressable (nesting would be an a11y nested-interactive violation).
41
+ */
42
+ export function ExerciseHeading({
43
+ name,
44
+ sets,
45
+ reps,
46
+ load,
47
+ unit = 'lbs',
48
+ tempo,
49
+ indicator,
50
+ dimmed,
51
+ onPress,
52
+ }: ExerciseHeadingProps) {
53
+ return (
54
+ <View style={{ opacity: dimmed ? 0.55 : 1 }} testID="exercise-heading">
55
+ <Pressable
56
+ onPress={onPress}
57
+ accessibilityRole="button"
58
+ accessibilityLabel={headingLabel(name, sets, reps, load, unit)}
59
+ className="flex-row items-center"
60
+ style={{ gap: 8 }}
61
+ testID="exercise-card-header"
62
+ >
63
+ <Text
64
+ className="text-text-primary"
65
+ style={{ fontSize: 14, fontFamily: '"Space Grotesk", sans-serif', fontWeight: '700' }}
66
+ testID="exercise-card-name"
67
+ >
68
+ {name}
69
+ </Text>
70
+ <View className="flex-1" />
71
+ {indicator && <ExerciseIndicator kind={indicator} />}
72
+ </Pressable>
73
+
74
+ <View
75
+ className="flex-row items-center"
76
+ style={{ gap: 8, marginTop: 1 }}
77
+ testID="exercise-card-summary"
78
+ >
79
+ <SetsRepsLoad sets={sets} reps={reps} load={load} unit={unit} />
80
+ <View className="flex-1" />
81
+ {tempo && <TempoDisplay tempo={tempo} size="sm" showLabel={false} showInfo={false} />}
82
+ </View>
83
+ </View>
84
+ )
85
+ }
@@ -0,0 +1,110 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import {
4
+ ExerciseIndicator,
5
+ resolveIndicator,
6
+ INDICATOR_PRECEDENCE,
7
+ type ExerciseIndicatorKind,
8
+ } from './ExerciseIndicator'
9
+
10
+ /**
11
+ * `ExerciseIndicator` — a small circular OUTLINED chip in an exercise heading's
12
+ * title line. The LOCKED taxonomy is 6 precedence-ranked kinds across four tier
13
+ * colors (danger/warning/success/info): glyph = the specific signal, color = the
14
+ * severity tier. Only one chip is ever shown per exercise — {@link resolveIndicator}
15
+ * picks the highest-precedence active signal.
16
+ */
17
+ const meta: Meta<typeof ExerciseIndicator> = {
18
+ title: 'Workout/ExerciseIndicator',
19
+ component: ExerciseIndicator,
20
+ tags: ['autodocs'],
21
+ parameters: {
22
+ docs: {
23
+ description: {
24
+ component:
25
+ '**Atom.** A small circular outlined chip in an exercise heading title line. Six ' +
26
+ 'precedence-ranked kinds (imbalance › overshoot › velocity-loss › missed-reps › pr › ' +
27
+ 'info) over four status-token tier colors; glyph = signal, color = severity tier. ' +
28
+ '`resolveIndicator()` selects the single chip to show. Used-by ↑ ' +
29
+ '[ExerciseHeading](?path=/docs/workout-exerciseheading--docs).',
30
+ },
31
+ },
32
+ },
33
+ argTypes: {
34
+ kind: { control: 'select', options: [...INDICATOR_PRECEDENCE] },
35
+ onPress: { action: 'press' },
36
+ },
37
+ decorators: [
38
+ (Story) => (
39
+ <View style={{ padding: 24, backgroundColor: '#131313' }}>
40
+ <Story />
41
+ </View>
42
+ ),
43
+ ],
44
+ }
45
+
46
+ export default meta
47
+ type Story = StoryObj<typeof ExerciseIndicator>
48
+
49
+ export const Imbalance: Story = { args: { kind: 'imbalance' } }
50
+ export const Overshoot: Story = { args: { kind: 'overshoot' } }
51
+ export const VelocityLoss: Story = { args: { kind: 'velocity-loss' } }
52
+ export const MissedReps: Story = { args: { kind: 'missed-reps' } }
53
+ export const PersonalRecord: Story = { args: { kind: 'pr' } }
54
+ export const Info: Story = { args: { kind: 'info' } }
55
+
56
+ const KIND_LABELS: Record<ExerciseIndicatorKind, string> = {
57
+ imbalance: 'imbalance · danger',
58
+ overshoot: 'overshoot · danger',
59
+ 'velocity-loss': 'velocity-loss · warning',
60
+ 'missed-reps': 'missed-reps · warning',
61
+ pr: 'pr · success',
62
+ info: 'info · info',
63
+ }
64
+
65
+ /** All six kinds in precedence order (1=highest → 6=lowest). */
66
+ export const AllKinds: Story = {
67
+ render: () => (
68
+ <View style={{ gap: 12 }}>
69
+ {INDICATOR_PRECEDENCE.map((kind, i) => (
70
+ <View key={kind} style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
71
+ <Text style={{ color: '#8A8A8A', fontSize: 11, width: 14 }}>{i + 1}</Text>
72
+ <ExerciseIndicator kind={kind} />
73
+ <Text style={{ color: '#CFCFCF', fontSize: 12 }}>{KIND_LABELS[kind]}</Text>
74
+ </View>
75
+ ))}
76
+ </View>
77
+ ),
78
+ }
79
+
80
+ /**
81
+ * `resolveIndicator` demo — each row is a set of active signals; the resolver
82
+ * collapses them to the single highest-precedence chip actually shown.
83
+ */
84
+ export const ResolvePrecedence: Story = {
85
+ render: () => {
86
+ const scenarios: ExerciseIndicatorKind[][] = [
87
+ ['info', 'pr'],
88
+ ['pr', 'missed-reps'],
89
+ ['velocity-loss', 'overshoot', 'info'],
90
+ ['missed-reps', 'imbalance', 'pr'],
91
+ ]
92
+ return (
93
+ <View style={{ gap: 12 }}>
94
+ {scenarios.map((candidates, i) => {
95
+ const winner = resolveIndicator(candidates)
96
+ return (
97
+ <View key={i} style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
98
+ <Text style={{ color: '#8A8A8A', fontSize: 12, width: 200 }}>
99
+ {candidates.join(', ')}
100
+ </Text>
101
+ <Text style={{ color: '#8A8A8A', fontSize: 12 }}>→</Text>
102
+ {winner ? <ExerciseIndicator kind={winner} /> : null}
103
+ <Text style={{ color: '#CFCFCF', fontSize: 12 }}>{winner}</Text>
104
+ </View>
105
+ )
106
+ })}
107
+ </View>
108
+ )
109
+ },
110
+ }