@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,88 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+ import { alpha } from '../../../utils/colors'
5
+ import { StatusDot, type StatusDotVariant } from './StatusDot'
6
+
7
+ const t = getSemanticColors('dark')
8
+
9
+ /**
10
+ * Auto-regulation verdict — the human-readable read-once summary of a set's
11
+ * velocity-loss state.
12
+ * - `productive` — in the band, keep going (success)
13
+ * - `threshold` — VL20 reached, approaching fatigue (warning)
14
+ * - `stop` — VL28+, terminate the set (error)
15
+ */
16
+ export type StatusPillStatus = 'productive' | 'threshold' | 'stop'
17
+
18
+ export interface StatusPillProps extends ViewProps {
19
+ status: StatusPillStatus
20
+ /** Overrides the default verdict text for the status. */
21
+ label?: string
22
+ className?: string
23
+ }
24
+
25
+ const statusToken: Record<StatusPillStatus, 'status-success' | 'status-warning' | 'status-error'> =
26
+ {
27
+ productive: 'status-success',
28
+ threshold: 'status-warning',
29
+ stop: 'status-error',
30
+ }
31
+
32
+ const statusDotVariant: Record<StatusPillStatus, StatusDotVariant> = {
33
+ productive: 'success',
34
+ threshold: 'warning',
35
+ stop: 'error',
36
+ }
37
+
38
+ const defaultLabel: Record<StatusPillStatus, string> = {
39
+ productive: 'Productive',
40
+ threshold: 'Threshold',
41
+ stop: 'Stop',
42
+ }
43
+
44
+ /** Resolved semantic color for a verdict status. */
45
+ export function statusPillColor(status: StatusPillStatus): string {
46
+ return t[statusToken[status]]
47
+ }
48
+
49
+ /**
50
+ * Verdict pill: a glowing status dot + colored verdict text, in a tinted,
51
+ * bordered capsule. Composes {@link StatusDot} for the dot so the glow/color
52
+ * stays consistent with the atom. Tint and border are derived from the same
53
+ * semantic token via `alpha()` — no per-status raw hex.
54
+ */
55
+ export function StatusPill({ status, label, className, style, ...props }: StatusPillProps) {
56
+ const color = statusPillColor(status)
57
+ const text = label ?? defaultLabel[status]
58
+
59
+ return (
60
+ <View
61
+ testID="status-pill"
62
+ className={className}
63
+ style={[
64
+ {
65
+ flexDirection: 'row',
66
+ alignItems: 'center',
67
+ gap: 7,
68
+ alignSelf: 'flex-start',
69
+ paddingVertical: 7,
70
+ paddingHorizontal: 13,
71
+ borderRadius: 9,
72
+ backgroundColor: alpha(color, 0.14),
73
+ borderWidth: 1,
74
+ borderColor: alpha(color, 0.4),
75
+ },
76
+ style,
77
+ ]}
78
+ {...props}
79
+ >
80
+ <View accessibilityElementsHidden>
81
+ <StatusDot variant={statusDotVariant[status]} glow />
82
+ </View>
83
+ <Text testID="status-pill-label" style={{ fontSize: 13, fontWeight: '800', color }}>
84
+ {text}
85
+ </Text>
86
+ </View>
87
+ )
88
+ }
@@ -32,7 +32,7 @@ const decline: StrengthTrendDataPoint[] = [
32
32
  ]
33
33
 
34
34
  const meta: Meta<typeof StrengthTrendChart> = {
35
- title: 'Custom/Workout/StrengthTrendChart',
35
+ title: 'Workout/DataViz/StrengthTrendChart',
36
36
  component: StrengthTrendChart,
37
37
  tags: ['autodocs'],
38
38
  argTypes: {
@@ -4,7 +4,7 @@ import { SupersetWrapper } from './SupersetWrapper'
4
4
  import { ExerciseCard } from './ExerciseCard'
5
5
 
6
6
  const meta: Meta<typeof SupersetWrapper> = {
7
- title: 'Custom/Workout/SupersetWrapper',
7
+ title: 'Workout/SupersetWrapper',
8
8
  component: SupersetWrapper,
9
9
  tags: ['autodocs'],
10
10
  decorators: [
@@ -3,7 +3,7 @@ import { View } from 'react-native'
3
3
  import { TempoBar } from './TempoBar'
4
4
 
5
5
  const meta: Meta<typeof TempoBar> = {
6
- title: 'Custom/Workout/TempoBar',
6
+ title: 'Workout/TempoBar',
7
7
  component: TempoBar,
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
@@ -15,6 +15,16 @@ const meta: Meta<typeof TempoBar> = {
15
15
  phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
16
16
  completed: { control: 'object', description: 'Completed phase durations (ms)' },
17
17
  target: { control: 'object', description: 'Per-phase target durations (seconds)' },
18
+ size: {
19
+ control: 'inline-radio',
20
+ options: ['default', 'wall'],
21
+ description: 'Density: default (compact) or wall (across-the-room dashboard scale)',
22
+ },
23
+ activeDisplay: {
24
+ control: 'inline-radio',
25
+ options: ['time', 'delta'],
26
+ description: 'Active readout: time (elapsed/target) or delta (countdown). Tap to toggle.',
27
+ },
18
28
  },
19
29
  decorators: [
20
30
  (Story) => (
@@ -93,3 +103,62 @@ export const AllStates: Story = {
93
103
  </View>
94
104
  ),
95
105
  }
106
+
107
+ /**
108
+ * The across-the-room wall scale — bigger bar, full phase words, and colour-coded
109
+ * pacing (a missed target reads red). Here the eccentric ran long → red.
110
+ */
111
+ export const Wall: Story = {
112
+ args: {
113
+ activePhase: null,
114
+ phaseElapsedMs: 0,
115
+ completed: { concentric: 950, hold: 1000, eccentric: 4200 },
116
+ target: TARGET,
117
+ size: 'wall',
118
+ },
119
+ decorators: [
120
+ (Story) => (
121
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
122
+ <Story />
123
+ </View>
124
+ ),
125
+ ],
126
+ }
127
+
128
+ /** Wall, mid-rep — the eccentric is active, showing the delta countdown (1.5s to target). Tap to switch to elapsed/target. */
129
+ export const WallActiveDelta: Story = {
130
+ args: {
131
+ activePhase: 'eccentric',
132
+ phaseElapsedMs: 1500,
133
+ completed: { concentric: 950, hold: 1000 },
134
+ target: TARGET,
135
+ size: 'wall',
136
+ activeDisplay: 'delta',
137
+ },
138
+ decorators: [
139
+ (Story) => (
140
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
141
+ <Story />
142
+ </View>
143
+ ),
144
+ ],
145
+ }
146
+
147
+ /** Wall, over target — the eccentric ran 1.2s past its target, so the delta is negative and red. */
148
+ export const WallActiveOver: Story = {
149
+ args: {
150
+ activePhase: 'eccentric',
151
+ phaseElapsedMs: 4200,
152
+ completed: { concentric: 950, hold: 1000 },
153
+ target: TARGET,
154
+ size: 'wall',
155
+ activeDisplay: 'delta',
156
+ },
157
+ decorators: [
158
+ (Story) => (
159
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
160
+ <Story />
161
+ </View>
162
+ ),
163
+ ],
164
+ }
@@ -1,13 +1,16 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { render, screen } from '@testing-library/react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import {
5
5
  TempoBar,
6
6
  getTempoPacingState,
7
7
  getTempoFillPct,
8
+ formatTempoDelta,
8
9
  TEMPO_PACING,
9
10
  } from './TempoBar'
11
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
10
12
 
13
+ const t = getSemanticColors('dark')
11
14
  const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
12
15
 
13
16
  describe('getTempoPacingState', () => {
@@ -70,7 +73,7 @@ describe('TempoBar', () => {
70
73
  phaseElapsedMs={100}
71
74
  completed={{ concentric: 950 }}
72
75
  target={TARGET}
73
- />,
76
+ />
74
77
  )
75
78
  expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
76
79
  expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
@@ -83,7 +86,7 @@ describe('TempoBar', () => {
83
86
  phaseElapsedMs={100}
84
87
  completed={{ eccentric: 4000 }}
85
88
  target={TARGET}
86
- />,
89
+ />
87
90
  )
88
91
  // eccentric target 3s, 4s elapsed => behind => cross
89
92
  expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
@@ -96,8 +99,120 @@ describe('TempoBar', () => {
96
99
  phaseElapsedMs={1500}
97
100
  completed={{ concentric: 900, hold: 1000 }}
98
101
  target={TARGET}
99
- />,
102
+ />
100
103
  )
101
104
  expect(await axe(container)).toHaveNoViolations()
102
105
  })
103
106
  })
107
+
108
+ describe('TempoBar size="wall"', () => {
109
+ const TARGET_W = { concentric: 1, hold: 1, eccentric: 3 }
110
+
111
+ it('scales the active label up at wall density', () => {
112
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} size="wall" />)
113
+ expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 22 })
114
+ })
115
+
116
+ it('keeps the compact label size by default', () => {
117
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} />)
118
+ expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 12 })
119
+ })
120
+
121
+ it('preserves the segment testIDs at wall density', () => {
122
+ render(
123
+ <TempoBar
124
+ activePhase="eccentric"
125
+ phaseElapsedMs={1500}
126
+ completed={{ concentric: 900 }}
127
+ target={TARGET_W}
128
+ size="wall"
129
+ />
130
+ )
131
+ expect(screen.getByTestId('tempo-segment-active-Ecc')).toBeInTheDocument()
132
+ expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
133
+ })
134
+
135
+ it('spells out the full phase words at wall density', () => {
136
+ render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET_W} size="wall" />)
137
+ expect(screen.getByText('Concentric')).toBeInTheDocument()
138
+ expect(screen.getByText('Eccentric')).toBeInTheDocument()
139
+ expect(screen.queryByText('Con')).not.toBeInTheDocument()
140
+ })
141
+ })
142
+
143
+ describe('formatTempoDelta', () => {
144
+ it('shows remaining time to target as a positive value', () => {
145
+ expect(formatTempoDelta(1500)).toBe('1.5')
146
+ })
147
+
148
+ it('shows over-target time as negative', () => {
149
+ expect(formatTempoDelta(-1200)).toBe('-1.2')
150
+ })
151
+
152
+ it('shows 0.0 at the target (no -0.0)', () => {
153
+ expect(formatTempoDelta(0)).toBe('0.0')
154
+ expect(formatTempoDelta(-10)).toBe('0.0')
155
+ })
156
+ })
157
+
158
+ describe('TempoBar activeDisplay (delta countdown + toggle)', () => {
159
+ it('counts the remaining time down to the target in delta mode', () => {
160
+ render(
161
+ <TempoBar
162
+ activePhase="eccentric"
163
+ phaseElapsedMs={1800}
164
+ target={TARGET}
165
+ activeDisplay="delta"
166
+ />
167
+ )
168
+ // eccentric target 3.0s, elapsed 1.8s → 1.2s remaining
169
+ expect(screen.getByText('1.2')).toBeInTheDocument()
170
+ })
171
+
172
+ it('goes negative once the phase runs past its target', () => {
173
+ render(
174
+ <TempoBar
175
+ activePhase="eccentric"
176
+ phaseElapsedMs={4200}
177
+ target={TARGET}
178
+ activeDisplay="delta"
179
+ />
180
+ )
181
+ expect(screen.getByText('-1.2')).toBeInTheDocument()
182
+ })
183
+
184
+ it('tapping the active segment toggles time ↔ delta', () => {
185
+ render(
186
+ <TempoBar
187
+ activePhase="concentric"
188
+ phaseElapsedMs={600}
189
+ target={TARGET}
190
+ activeDisplay="time"
191
+ />
192
+ )
193
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
194
+ fireEvent.click(screen.getByTestId('tempo-segment-active-Con'))
195
+ // concentric target 1.0s, elapsed 0.6s → 0.4s remaining
196
+ expect(screen.getByText('0.4')).toBeInTheDocument()
197
+ expect(screen.queryByText('0.6 / 1.0')).not.toBeInTheDocument()
198
+ })
199
+
200
+ it('defaults to the time readout', () => {
201
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
202
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
203
+ })
204
+ })
205
+
206
+ describe('TempoBar completed pacing colour', () => {
207
+ it('colours a missed completed phase red', () => {
208
+ render(
209
+ <TempoBar
210
+ activePhase="hold"
211
+ phaseElapsedMs={100}
212
+ completed={{ eccentric: 4000 }}
213
+ target={TARGET}
214
+ />
215
+ )
216
+ expect(screen.getByText('4.0 ✗')).toHaveStyle({ color: t['status-error'] })
217
+ })
218
+ })
@@ -1,5 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { View, Text, type ViewProps } from 'react-native'
2
+ import { useState } from 'react'
3
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
4
  import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
  import { alpha } from '../../../utils/colors'
5
6
 
@@ -19,15 +20,23 @@ export const TEMPO_PACING = {
19
20
 
20
21
  export type TempoPacingState = 'none' | 'on-pace' | 'behind'
21
22
 
23
+ /** Active-phase readout mode: elapsed/target `time`, or a `delta` countdown to the target. */
24
+ export type TempoActiveDisplay = 'time' | 'delta'
25
+
26
+ /** Signed seconds to one decimal — the delta countdown (`+` remaining, `-` over target). */
27
+ export function formatTempoDelta(remainingMs: number): string {
28
+ const s = remainingMs / 1000
29
+ // Guard `-0.0`; show a leading minus only once past target.
30
+ const rounded = Math.abs(s) < 0.05 ? 0 : s
31
+ return rounded.toFixed(1)
32
+ }
33
+
22
34
  /**
23
35
  * Classify how a phase's elapsed time tracks against its target duration.
24
36
  * `none` when there is no meaningful target; `behind` once elapsed exceeds the
25
37
  * target by more than the behind threshold; `on-pace` otherwise.
26
38
  */
27
- export function getTempoPacingState(
28
- elapsedMs: number,
29
- targetMs: number | null,
30
- ): TempoPacingState {
39
+ export function getTempoPacingState(elapsedMs: number, targetMs: number | null): TempoPacingState {
31
40
  if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
32
41
  const ratio = elapsedMs / targetMs
33
42
  if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
@@ -41,7 +50,10 @@ export function getTempoFillPct(elapsedMs: number, targetMs: number | null): num
41
50
  }
42
51
 
43
52
  interface PhaseConfig {
53
+ /** Compact label + the stable testID suffix. */
44
54
  label: string
55
+ /** Full phase word, spelled out at `wall` density where there's room to read it. */
56
+ wallLabel: string
45
57
  color: string
46
58
  flex: number
47
59
  }
@@ -49,9 +61,27 @@ interface PhaseConfig {
49
61
  const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
50
62
 
51
63
  const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
52
- concentric: { label: 'Con', color: t['status-success'], flex: 2 },
53
- hold: { label: 'Hold', color: t['brand-primary'], flex: 1 },
54
- eccentric: { label: 'Ecc', color: t['status-warning'], flex: 3 },
64
+ concentric: { label: 'Con', wallLabel: 'Concentric', color: t['status-success'], flex: 2 },
65
+ hold: { label: 'Hold', wallLabel: 'Hold', color: t['brand-primary'], flex: 1 },
66
+ eccentric: { label: 'Ecc', wallLabel: 'Eccentric', color: t['status-warning'], flex: 3 },
67
+ }
68
+
69
+ /** Density. `default` — the compact card/rail treatment. `wall` — the across-the-room dashboard scale. */
70
+ export type TempoBarSize = 'default' | 'wall'
71
+
72
+ interface TempoBarSizing {
73
+ labelMargin: number
74
+ labelFont: number
75
+ barGap: number
76
+ barHeight: number
77
+ radius: number
78
+ segFont: number
79
+ }
80
+
81
+ /** Per-density magnitudes. `default` reproduces the original compact values exactly. */
82
+ const TEMPO_SIZES: Record<TempoBarSize, TempoBarSizing> = {
83
+ default: { labelMargin: 4, labelFont: 10, barGap: 2, barHeight: 20, radius: 4, segFont: 12 },
84
+ wall: { labelMargin: 8, labelFont: 15, barGap: 4, barHeight: 40, radius: 6, segFont: 22 },
55
85
  }
56
86
 
57
87
  export interface TempoBarProps extends ViewProps {
@@ -63,6 +93,14 @@ export interface TempoBarProps extends ViewProps {
63
93
  completed?: Partial<Record<TempoPhaseKey, number>>
64
94
  /** Optional per-phase target durations (seconds) for pacing feedback. */
65
95
  target?: Partial<Record<TempoPhaseKey, number>>
96
+ /** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). */
97
+ size?: TempoBarSize
98
+ /**
99
+ * Active-phase readout. `time` (default) — `elapsed / target`. `delta` — a countdown
100
+ * of the remaining time to target (`1.5` → `0.0` → `-1.2` once over), colour-coded like
101
+ * the bar. Tapping the active segment toggles between the two at runtime.
102
+ */
103
+ activeDisplay?: TempoActiveDisplay
66
104
  className?: string
67
105
  }
68
106
 
@@ -79,25 +117,33 @@ export function TempoBar({
79
117
  phaseElapsedMs,
80
118
  completed,
81
119
  target,
120
+ size = 'default',
121
+ activeDisplay = 'time',
82
122
  className,
83
123
  ...props
84
124
  }: TempoBarProps) {
125
+ const s = TEMPO_SIZES[size]
126
+ // Runtime toggle of the active readout, seeded from `activeDisplay`.
127
+ const [display, setDisplay] = useState<TempoActiveDisplay>(activeDisplay)
128
+ const toggleDisplay = () => setDisplay((d) => (d === 'time' ? 'delta' : 'time'))
85
129
  return (
86
130
  <View className={className} testID="tempo-bar" {...props}>
87
131
  {/* Phase labels */}
88
- <View style={{ flexDirection: 'row', marginBottom: 4 }}>
132
+ <View style={{ flexDirection: 'row', marginBottom: s.labelMargin }}>
89
133
  {PHASE_ORDER.map((phase) => {
90
134
  const config = PHASE_CONFIG[phase]
91
135
  return (
92
136
  <View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
93
- <Text style={{ fontSize: 10, color: t['text-disabled'] }}>{config.label}</Text>
137
+ <Text numberOfLines={1} style={{ fontSize: s.labelFont, color: t['text-disabled'] }}>
138
+ {size === 'wall' ? config.wallLabel : config.label}
139
+ </Text>
94
140
  </View>
95
141
  )
96
142
  })}
97
143
  </View>
98
144
 
99
145
  {/* Segmented bar */}
100
- <View style={{ flexDirection: 'row', gap: 2, height: 20 }}>
146
+ <View style={{ flexDirection: 'row', gap: s.barGap, height: s.barHeight }}>
101
147
  {PHASE_ORDER.map((phase) => {
102
148
  const config = PHASE_CONFIG[phase]
103
149
  const isActive = activePhase === phase
@@ -111,7 +157,7 @@ export function TempoBar({
111
157
  style={{
112
158
  flex: config.flex,
113
159
  backgroundColor: alpha('#ffffff', 0.06),
114
- borderRadius: 4,
160
+ borderRadius: s.radius,
115
161
  overflow: 'hidden',
116
162
  }}
117
163
  >
@@ -120,9 +166,18 @@ export function TempoBar({
120
166
  config={config}
121
167
  phaseElapsedMs={phaseElapsedMs}
122
168
  targetMs={targetMs}
169
+ radius={s.radius}
170
+ font={s.segFont}
171
+ display={display}
172
+ onToggleDisplay={toggleDisplay}
123
173
  />
124
174
  ) : completedMs != null ? (
125
- <CompletedSegment config={config} completedMs={completedMs} targetMs={targetMs} />
175
+ <CompletedSegment
176
+ config={config}
177
+ completedMs={completedMs}
178
+ targetMs={targetMs}
179
+ font={s.segFont}
180
+ />
126
181
  ) : null}
127
182
  </View>
128
183
  )
@@ -136,21 +191,40 @@ function ActiveSegment({
136
191
  config,
137
192
  phaseElapsedMs,
138
193
  targetMs,
194
+ radius,
195
+ font,
196
+ display,
197
+ onToggleDisplay,
139
198
  }: {
140
199
  config: PhaseConfig
141
200
  phaseElapsedMs: number
142
201
  targetMs: number | null
202
+ radius: number
203
+ font: number
204
+ display: TempoActiveDisplay
205
+ onToggleDisplay: () => void
143
206
  }) {
144
207
  const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
145
208
  const barColor = pacing === 'behind' ? t['status-error'] : config.color
146
209
  const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
147
210
 
148
- const label = targetMs
149
- ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
150
- : formatDuration(phaseElapsedMs)
211
+ // `delta`: count the remaining time to target down to 0.0, then negative once over.
212
+ // Falls back to elapsed when there's no target. `time`: the elapsed / target readout.
213
+ const label =
214
+ display === 'delta' && targetMs != null
215
+ ? formatTempoDelta(targetMs - phaseElapsedMs)
216
+ : targetMs != null
217
+ ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
218
+ : formatDuration(phaseElapsedMs)
151
219
 
152
220
  return (
153
- <View style={{ height: '100%', position: 'relative' }} testID={`tempo-segment-active-${config.label}`}>
221
+ <Pressable
222
+ onPress={onToggleDisplay}
223
+ style={{ height: '100%', position: 'relative' }}
224
+ accessibilityRole="button"
225
+ accessibilityLabel={`Tempo readout: ${display === 'delta' ? 'delta' : 'time'} — tap to switch`}
226
+ testID={`tempo-segment-active-${config.label}`}
227
+ >
154
228
  <View
155
229
  style={{
156
230
  position: 'absolute',
@@ -159,7 +233,7 @@ function ActiveSegment({
159
233
  bottom: 0,
160
234
  width: `${fillPct}%`,
161
235
  backgroundColor: alpha(barColor, 0.3),
162
- borderRadius: 4,
236
+ borderRadius: radius,
163
237
  }}
164
238
  />
165
239
  <View
@@ -170,9 +244,9 @@ function ActiveSegment({
170
244
  justifyContent: 'center',
171
245
  }}
172
246
  >
173
- <Text style={{ fontSize: 12, fontWeight: '700', color: barColor }}>{label}</Text>
247
+ <Text style={{ fontSize: font, fontWeight: '700', color: barColor }}>{label}</Text>
174
248
  </View>
175
- </View>
249
+ </Pressable>
176
250
  )
177
251
  }
178
252
 
@@ -180,15 +254,20 @@ function CompletedSegment({
180
254
  config,
181
255
  completedMs,
182
256
  targetMs,
257
+ font,
183
258
  }: {
184
259
  config: PhaseConfig
185
260
  completedMs: number
186
261
  targetMs: number | null
262
+ font: number
187
263
  }) {
188
264
  const pacing = getTempoPacingState(completedMs, targetMs)
189
265
  const hitTarget = pacing !== 'behind'
190
266
  const indicator =
191
267
  targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
268
+ // A missed target reads by COLOUR first (red fill + red duration), the ✓/✗ second —
269
+ // glanceable across a room; a hit keeps the phase's own colour.
270
+ const cueColor = hitTarget ? config.color : t['status-error']
192
271
 
193
272
  return (
194
273
  <View
@@ -197,11 +276,17 @@ function CompletedSegment({
197
276
  flexDirection: 'row',
198
277
  alignItems: 'center',
199
278
  justifyContent: 'center',
200
- backgroundColor: alpha(config.color, 0.15),
279
+ backgroundColor: alpha(cueColor, 0.15),
201
280
  }}
202
281
  testID={`tempo-segment-completed-${config.label}`}
203
282
  >
204
- <Text style={{ fontSize: 12, fontWeight: '500', color: alpha(config.color, 0.7) }}>
283
+ <Text
284
+ style={{
285
+ fontSize: font,
286
+ fontWeight: '500',
287
+ color: hitTarget ? alpha(config.color, 0.7) : cueColor,
288
+ }}
289
+ >
205
290
  {formatDuration(completedMs)}
206
291
  {indicator}
207
292
  </Text>
@@ -5,9 +5,20 @@ import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoD
5
5
 
6
6
  // tempo = [eccentric, pauseBottom, concentric, pauseTop]
7
7
  const meta: Meta<typeof TempoDisplay> = {
8
- title: 'Custom/Workout/TempoDisplay',
8
+ title: 'Workout/TempoDisplay',
9
9
  component: TempoDisplay,
10
10
  tags: ['autodocs'],
11
+ parameters: {
12
+ docs: {
13
+ description: {
14
+ component:
15
+ '**Molecule.** The eccentric/pause/concentric/pause tempo display. Composes ' +
16
+ '[MetricCell](?path=/docs/workout-metriccell--docs) ' +
17
+ '(shared value/separator cell). Used-by ↑ ' +
18
+ '[ExerciseHeading](?path=/docs/workout-exerciseheading--docs) (`showLabel={false}`).',
19
+ },
20
+ },
21
+ },
11
22
  argTypes: {
12
23
  tempo: {
13
24
  control: 'object',