@titan-design/react-ui 0.10.0 → 0.12.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 (190) hide show
  1. package/dist/bodymap.js +173 -343
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +173 -343
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +416 -18
  6. package/dist/index.d.ts +416 -18
  7. package/dist/index.js +2512 -1307
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2480 -1299
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  12. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1644 -1575
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1645 -1576
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +409 -415
  20. package/dist/theme/index.d.ts +409 -415
  21. package/dist/theme/index.js +234 -408
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +227 -399
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +141 -140
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +141 -140
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/arch/Architecture.stories.tsx +9 -9
  31. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  32. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  33. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  34. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  35. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  36. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  37. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  38. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  39. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  40. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  41. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  42. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  43. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  45. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  46. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  47. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  48. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  49. package/src/components/custom/Fatigue/README.md +93 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  51. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  52. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  53. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  54. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  55. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  56. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  57. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  58. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  59. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  60. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  61. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  62. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  63. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  64. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  65. package/src/components/custom/Fatigue/index.ts +41 -0
  66. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  67. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  68. package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
  69. package/src/components/custom/Table/Table.tsx +2 -2
  70. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
  71. package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
  72. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  73. package/src/components/custom/Workout/BodyMap.tsx +2 -2
  74. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
  75. package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
  76. package/src/components/custom/Workout/DeviationBar.tsx +3 -2
  77. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  78. package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
  79. package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
  80. package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
  81. package/src/components/custom/Workout/InputBar.tsx +2 -2
  82. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
  83. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
  84. package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
  85. package/src/components/custom/Workout/MesoCard.tsx +3 -1
  86. package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
  87. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  88. package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
  89. package/src/components/custom/Workout/README.md +62 -16
  90. package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
  91. package/src/components/custom/Workout/RestTimer.tsx +2 -2
  92. package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
  93. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  94. package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
  95. package/src/components/custom/Workout/SessionHeader.tsx +1 -1
  96. package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
  97. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  98. package/src/components/custom/Workout/SessionRail.tsx +63 -18
  99. package/src/components/custom/Workout/SetBar.tsx +3 -3
  100. package/src/components/custom/Workout/SetRow.test.tsx +12 -9
  101. package/src/components/custom/Workout/SetRow.tsx +9 -2
  102. package/src/components/custom/Workout/StatusDot.tsx +5 -4
  103. package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
  104. package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
  105. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  106. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  107. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  108. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  109. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  110. package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
  111. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  112. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
  113. package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
  114. package/src/components/custom/Workout/WeightBadge.tsx +2 -1
  115. package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
  116. package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
  117. package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
  118. package/src/components/custom/Workout/index.ts +3 -0
  119. package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
  120. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  121. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  122. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  123. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  124. package/src/components/custom/index.ts +1 -0
  125. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  126. package/src/components/custom/stepper/Stepper.tsx +3 -3
  127. package/src/components/shell/DeviceMenu.tsx +2 -2
  128. package/src/components/shell/SideNav.tsx +1 -1
  129. package/src/components/shell/TopBar.tsx +1 -1
  130. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  131. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  132. package/src/components/ui/avatar/Avatar.tsx +2 -2
  133. package/src/components/ui/badge/Badge.tsx +2 -2
  134. package/src/components/ui/card/Card.tsx +3 -3
  135. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  136. package/src/components/ui/collapse/Collapse.tsx +1 -1
  137. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  138. package/src/components/ui/drawer/Drawer.tsx +3 -3
  139. package/src/components/ui/form-field/FormField.tsx +1 -1
  140. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  141. package/src/components/ui/menu/Menu.tsx +2 -2
  142. package/src/components/ui/pill/Pill.tsx +2 -2
  143. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  144. package/src/components/ui/popover/Popover.tsx +1 -1
  145. package/src/components/ui/progress/Progress.tsx +3 -3
  146. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  147. package/src/components/ui/radio/Radio.tsx +4 -4
  148. package/src/components/ui/select/Select.tsx +3 -3
  149. package/src/components/ui/surface/Surface.stories.tsx +3 -3
  150. package/src/components/ui/surface/Surface.test.tsx +32 -19
  151. package/src/components/ui/surface/Surface.tsx +3 -3
  152. package/src/components/ui/surface/SurfaceContext.ts +19 -29
  153. package/src/components/ui/surface/surface.contract.test.ts +57 -44
  154. package/src/components/ui/switch/Switch.tsx +1 -1
  155. package/src/components/ui/tabs/Tabs.tsx +2 -2
  156. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
  157. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
  158. package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
  159. package/src/stories/PresetShowcase.stories.tsx +21 -21
  160. package/src/test/raw-color-patterns.test.ts +71 -0
  161. package/src/theme/ColorPalettes.stories.tsx +874 -0
  162. package/src/theme/ColorPrimitives.stories.tsx +348 -0
  163. package/src/theme/Depth.stories.tsx +312 -0
  164. package/src/theme/DepthCalibration.stories.tsx +655 -0
  165. package/src/theme/ThemeProvider.tsx +1 -1
  166. package/src/theme/barrel.ts +7 -14
  167. package/src/theme/color-stories.coverage.test.ts +125 -0
  168. package/src/theme/color-story-kit.tsx +170 -0
  169. package/src/theme/color-utils.ts +197 -0
  170. package/src/theme/config.ts +0 -8
  171. package/src/theme/depth-calibration.test.tsx +97 -0
  172. package/src/theme/elevation.test.ts +62 -1
  173. package/src/theme/elevation.ts +34 -8
  174. package/src/theme/global.css +47 -53
  175. package/src/theme/grey-ramp.test.ts +166 -0
  176. package/src/theme/materials.test.ts +106 -0
  177. package/src/theme/materials.ts +211 -0
  178. package/src/theme/presets/audiobook.ts +4 -4
  179. package/src/theme/presets/types.ts +3 -3
  180. package/src/theme/semantic-contrast.test.ts +83 -0
  181. package/src/theme/tokens/primitives.ts +62 -115
  182. package/src/theme/tokens/semantic.ts +130 -82
  183. package/src/utils/colors.ts +1 -1
  184. package/tailwind.config.js +2 -5
  185. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  186. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  187. package/src/theme/ColorSystem.stories.tsx +0 -508
  188. package/src/theme/Colors.stories.tsx +0 -323
  189. package/src/theme/shadows.stories.tsx +0 -523
  190. package/src/theme/shadows.ts +0 -439
@@ -1,9 +1,12 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, type ViewProps } from 'react-native'
3
- import { primitiveColors } from '../../../theme/tokens/primitives'
4
- import { neumorphicShadows } from '../../../theme/shadows'
3
+ import { greyRamp } from '../../../theme/tokens/primitives'
4
+ import { insetWell } from '../../../theme/materials'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
6
  import { Surface } from '../../ui/surface'
6
7
  import { ExerciseCardHeading } from './ExerciseCardHeading'
8
+ import { ExerciseCard } from './ExerciseCard'
9
+ import type { SetRowProps } from './SetRow'
7
10
  import { SessionHeader } from './SessionHeader'
8
11
  import type { MetricTileData } from './MetricTiles'
9
12
  import type { SetStripSet } from './SetStrip'
@@ -15,7 +18,9 @@ import type { ExerciseIndicatorKind } from './ExerciseIndicator'
15
18
  // recessed via its inner shadow yet paler than the header, so the transparent
16
19
  // exercise headings on it never blend into the header plane. Surface owns both backgrounds,
17
20
  // so the rail no longer hand-sets them.
18
- const DIVIDER = primitiveColors.charcoal[300] // #2C2C2C — row divider (raised to read on surface-elevated #2A2827)
21
+ const SEMANTIC = getSemanticColors('dark')
22
+ /** Row divider — one step up from the well it sits in so the line reads. */
23
+ const DIVIDER = greyRamp[875]
19
24
 
20
25
  const DEFAULT_WIDTH = 246
21
26
 
@@ -38,6 +43,12 @@ export interface SessionRailExercise {
38
43
  setStates: SetStripSet[]
39
44
  /** Dim the row as a not-yet-reached exercise. */
40
45
  upcoming?: boolean
46
+ /**
47
+ * Per-set rows for the expanded body. Only read when this row is the one named
48
+ * by `expandedIndex` — a rail row without them can never expand, which is the
49
+ * correct behaviour for an exercise whose sets aren't loaded.
50
+ */
51
+ sets?: SetRowProps[]
41
52
  }
42
53
 
43
54
  export interface SessionRailProps extends ViewProps {
@@ -60,6 +71,16 @@ export interface SessionRailProps extends ViewProps {
60
71
  stripHeight?: number
61
72
  /** Rail width in px. Default 246. */
62
73
  width?: number
74
+ /**
75
+ * Index of the exercise to render EXPANDED in place — its set table opens
76
+ * inside the rail rather than in a separate pane. The row still draws the same
77
+ * `ExerciseCardHeading` (an `ExerciseCard` composes one), so an expanded row
78
+ * differs from a collapsed one only by the revealed body.
79
+ *
80
+ * Ignored when the named exercise has no `sets`. Omit for an all-collapsed
81
+ * rail, which is the prior behaviour.
82
+ */
83
+ expandedIndex?: number
63
84
  onExercisePress?: (exercise: SessionRailExercise, index: number) => void
64
85
  className?: string
65
86
  }
@@ -81,6 +102,7 @@ export function SessionRail({
81
102
  next,
82
103
  stripHeight = 8,
83
104
  width = DEFAULT_WIDTH,
105
+ expandedIndex,
84
106
  onExercisePress,
85
107
  className,
86
108
  style,
@@ -105,9 +127,14 @@ export function SessionRail({
105
127
  next={next}
106
128
  />
107
129
 
130
+ {/* The list is a WELL cut into the rail: one plane down from the rail's
131
+ `elevated`, recessed by the shared inset material rather than by a
132
+ neumorphic pressed shadow (which had no room to read at this
133
+ lightness). `level` follows the well's tone so on-surface text
134
+ resolves against what is actually painted. */}
108
135
  <Surface
109
- level="elevated"
110
- style={[{ flex: 1 }, neumorphicShadows.charcoal.pressed.subtle]}
136
+ level="base"
137
+ style={[{ flex: 1 }, insetWell(SEMANTIC['surface-base'])]}
111
138
  testID="session-rail-list"
112
139
  >
113
140
  {exercises.map((ex, i) => (
@@ -118,19 +145,37 @@ export function SessionRail({
118
145
  borderBottomColor: DIVIDER,
119
146
  }}
120
147
  >
121
- <ExerciseCardHeading
122
- name={ex.name}
123
- sets={ex.summary.sets}
124
- reps={ex.summary.reps}
125
- load={ex.summary.weight}
126
- unit={ex.summary.unit}
127
- tempo={ex.tempo}
128
- indicator={ex.indicator}
129
- setStates={ex.setStates}
130
- stripHeight={stripHeight}
131
- dimmed={ex.upcoming}
132
- onPress={() => onExercisePress?.(ex, i)}
133
- />
148
+ {i === expandedIndex && ex.sets ? (
149
+ <ExerciseCard
150
+ name={ex.name}
151
+ expanded
152
+ summary={{
153
+ sets: ex.summary.sets,
154
+ reps: ex.summary.reps,
155
+ // ExerciseCard's summary takes a numeric load; a string load is
156
+ // an unset/discovery weight, which has no expanded form yet.
157
+ weight: typeof ex.summary.weight === 'number' ? ex.summary.weight : 0,
158
+ unit: ex.summary.unit,
159
+ }}
160
+ tempo={ex.tempo}
161
+ sets={ex.sets}
162
+ onExpandedChange={() => onExercisePress?.(ex, i)}
163
+ />
164
+ ) : (
165
+ <ExerciseCardHeading
166
+ name={ex.name}
167
+ sets={ex.summary.sets}
168
+ reps={ex.summary.reps}
169
+ load={ex.summary.weight}
170
+ unit={ex.summary.unit}
171
+ tempo={ex.tempo}
172
+ indicator={ex.indicator}
173
+ setStates={ex.setStates}
174
+ stripHeight={stripHeight}
175
+ dimmed={ex.upcoming}
176
+ onPress={() => onExercisePress?.(ex, i)}
177
+ />
178
+ )}
134
179
  </View>
135
180
  ))}
136
181
  </Surface>
@@ -1,5 +1,5 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { primitiveColors, primitiveRamps } from '../../../theme/tokens/primitives'
2
+ import { primitiveRamps, greyRamp } from '../../../theme/tokens/primitives'
3
3
  import { SegmentedBar, type SegmentedBarSegment } from './SegmentedBar'
4
4
 
5
5
  /**
@@ -13,8 +13,8 @@ export const SET_STRIP_ZONES = {
13
13
  fastest: primitiveRamps.green[300],
14
14
  } as const
15
15
 
16
- /** Grey fill for planned-but-unperformed reps / upcoming sets (charcoal placeholder). */
17
- const TODO_COLOR = primitiveColors.charcoal[300]
16
+ /** Grey fill for planned-but-unperformed reps / upcoming sets (grey placeholder). */
17
+ const TODO_COLOR = greyRamp[900]
18
18
 
19
19
  /**
20
20
  * The learned "variable / unknown / opportunity" semantic (cyan-900, a real ramp
@@ -3,10 +3,13 @@ import { render, screen } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import { View } from 'react-native'
5
5
  import { SetRow, type SetRowProps } from './SetRow'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
7
 
7
- // Literal-hex text treatments (dark theme neutral 100 / 400).
8
- const T_ACTIVE = '#F3F4F6'
9
- const T_MUTED = '#9CA3AF'
8
+ // Text treatments, read from the tokens rather than pinned as hexes. These
9
+ // used to be hand-copied literals and silently desynced when the greys moved
10
+ // to the warm ramp (TD-07.14). The assertion still bites — it checks the
11
+ // component reaches for the right ROLE — it just no longer restates the value.
12
+ const { 'text-primary': T_ACTIVE, 'text-secondary': T_MUTED } = getSemanticColors('dark')
10
13
 
11
14
  const doneRow: SetRowProps = {
12
15
  state: 'done',
@@ -55,9 +58,9 @@ describe('SetRow', () => {
55
58
  expect(screen.getByText('8')).toHaveStyle({ color: T_MUTED })
56
59
  })
57
60
 
58
- it('renders a flat mini strip (done reps, no todo stubs)', () => {
61
+ it('renders the flat compact strip (done reps, no todo stubs)', () => {
59
62
  render(<SetRow {...doneRow} />)
60
- expect(screen.getByTestId('velocity-strip-mini')).toBeInTheDocument()
63
+ expect(screen.getByTestId('velocity-strip-compact')).toBeInTheDocument()
61
64
  expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(3)
62
65
  expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
63
66
  })
@@ -76,9 +79,9 @@ describe('SetRow', () => {
76
79
  expect(screen.getByText('2')).toHaveStyle({ color: T_ACTIVE })
77
80
  })
78
81
 
79
- it('renders the compact velocity-height spotlight strip', () => {
82
+ it('renders the velocity-height spotlight strip', () => {
80
83
  render(<SetRow {...liveRow} />)
81
- expect(screen.getByTestId('velocity-strip-compact')).toBeInTheDocument()
84
+ expect(screen.getByTestId('velocity-strip-spotlight')).toBeInTheDocument()
82
85
  // 2 performed reps + grey stubs to the target (10).
83
86
  expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
84
87
  expect(screen.getAllByTestId('velocity-slot-todo')).toHaveLength(8)
@@ -93,10 +96,10 @@ describe('SetRow', () => {
93
96
  expect(screen.getByTestId('set-row-rpe')).toHaveTextContent('—')
94
97
  })
95
98
 
96
- it('is muted and renders an all-grey mini stub strip', () => {
99
+ it('is muted and renders an all-grey compact stub strip', () => {
97
100
  render(<SetRow {...todoRow} />)
98
101
  expect(screen.getByText('3')).toHaveStyle({ color: T_MUTED })
99
- expect(screen.getByTestId('velocity-strip-mini')).toBeInTheDocument()
102
+ expect(screen.getByTestId('velocity-strip-compact')).toBeInTheDocument()
100
103
  expect(screen.getAllByTestId('velocity-slot-todo')).toHaveLength(10)
101
104
  expect(screen.queryByTestId(/^velocity-bar-\d+$/)).not.toBeInTheDocument()
102
105
  })
@@ -120,7 +120,7 @@ function Cell({
120
120
  )
121
121
  }
122
122
 
123
- /** The per-row velocity strip: compact spotlight for the live set, flat mini otherwise. */
123
+ /** The per-row velocity strip: the velocity-height `expanded` spotlight for the live set, the flat `compact` strip otherwise. */
124
124
  function RowStrip({ set }: { set: SetRowProps }) {
125
125
  const zones = set.velocityZones
126
126
  if (set.state === 'live') {
@@ -133,15 +133,22 @@ function RowStrip({ set }: { set: SetRowProps }) {
133
133
  showInfo={false}
134
134
  height={24}
135
135
  scale="fixed"
136
+ // Grow the newest rep from the baseline as it lands (the live-set spotlight).
137
+ liveRepIndex={set.velocities.length - 1}
136
138
  set={{ type: 'straight', velocities: set.velocities, planned: set.target.reps }}
137
139
  zones={zones}
138
140
  />
139
141
  )
140
142
  }
141
143
  const velocities = set.state === 'done' ? set.velocities : []
144
+ // Resting (done / todo) → the flat `compact` strip at the 8px SegmentedBar flat-bar language (the
145
+ // static compact bar fills its plot), so the resting per-rep strip matches the set-level segmented
146
+ // bars used elsewhere; the live spotlight above stays taller (24px).
142
147
  return (
143
148
  <VelocityStrip
144
- variant="mini"
149
+ variant="compact"
150
+ height={8}
151
+ hideBaseline
145
152
  set={{ type: 'straight', velocities, planned: plannedReps(set) }}
146
153
  zones={zones}
147
154
  />
@@ -1,6 +1,7 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text, type ViewProps } from 'react-native'
3
3
  import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+ import { greyRamp } from '../../../theme/tokens/primitives'
4
5
 
5
6
  const t = getSemanticColors('dark')
6
7
 
@@ -28,7 +29,7 @@ const solidVariantColors: Record<string, string> = {
28
29
  success: t['status-success'],
29
30
  warning: t['status-warning'],
30
31
  error: t['status-error'],
31
- neutral: '#6B7280',
32
+ neutral: greyRamp[500],
32
33
  }
33
34
 
34
35
  const ringVariantStyles: Record<string, Record<string, unknown>> = {
@@ -71,10 +72,10 @@ const iconColors: Record<StatusDotVariant, string> = {
71
72
  success: '#0A5C52',
72
73
  warning: '#6B4000',
73
74
  error: '#5C1A1A',
74
- neutral: '#D1D5DB',
75
+ neutral: greyRamp[200],
75
76
  'on-track': t['status-success'],
76
77
  deviation: t['status-warning'],
77
- future: '#6B7280',
78
+ future: greyRamp[500],
78
79
  }
79
80
 
80
81
  const sizeDimensions = {
@@ -146,7 +147,7 @@ export function StatusDot({
146
147
  >
147
148
  <View accessibilityElementsHidden>{dot}</View>
148
149
  <Text
149
- style={{ fontSize: 12, lineHeight: 16, color: '#9CA3AF' }}
150
+ style={{ fontSize: 12, lineHeight: 16, color: greyRamp[400] }}
150
151
  accessibilityElementsHidden
151
152
  >
152
153
  {label}
@@ -477,7 +477,7 @@ export function StrengthTrendChart({
477
477
  <View
478
478
  testID="strength-trend-chart-tooltip"
479
479
  accessibilityElementsHidden
480
- className="bg-surface-elevated border-border-strong"
480
+ className="bg-surface-elevated border-hairline-strong"
481
481
  style={{
482
482
  position: 'absolute',
483
483
  left: Math.max(
@@ -114,7 +114,7 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
114
114
  return (
115
115
  <View
116
116
  key={key}
117
- className="bg-surface-elevated border-border"
117
+ className="bg-surface-elevated border-hairline"
118
118
  style={{
119
119
  flexGrow: 1,
120
120
  flexBasis: '46%',
@@ -265,7 +265,7 @@ export function TrainingStatusPage({
265
265
  <SummaryCards summary={summary} />
266
266
 
267
267
  <View
268
- className="bg-surface-elevated border-border"
268
+ className="bg-surface-elevated border-hairline"
269
269
  style={{
270
270
  borderWidth: 1,
271
271
  borderRadius: 12,
@@ -0,0 +1,191 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { VelocityStrip } from './VelocityStrip'
3
+ import {
4
+ Sheet,
5
+ Note,
6
+ ScenarioPair,
7
+ SetTypeBoard,
8
+ RepTypeBoard,
9
+ REP_SET,
10
+ REP_SET_LAGGING,
11
+ FATIGUE_SET,
12
+ FATIGUE_SET_LAGGING,
13
+ } from './velocity-story-kit'
14
+
15
+ /**
16
+ * `compact` — the flat resting strip. SetBarChart in flat mode: uniform short
17
+ * bars, no labels, no axis. The glance used in the SetRow table and on cards,
18
+ * where the bar is colour-encoded rather than height-encoded.
19
+ *
20
+ * The compact dual FOLDS into one footprint rather than stacking to 2x: each
21
+ * rep column splits at the centre into an L top pill and an R bottom pill.
22
+ * Because resting bars carry their meaning in colour, the pair costs the same
23
+ * vertical space as a single — 2x is only forced for the value-height expanded.
24
+ */
25
+ const meta: Meta<typeof VelocityStrip> = {
26
+ title: 'Workout/DataViz/VelocityStrip/Compact',
27
+ component: VelocityStrip,
28
+ tags: ['autodocs'],
29
+ }
30
+ export default meta
31
+ type Story = StoryObj<typeof VelocityStrip>
32
+
33
+ export const Default: Story = {
34
+ name: 'Default',
35
+ render: () => (
36
+ <Sheet>
37
+ <Note>
38
+ The representative set at rest. Single fills its plot; the dual folds the same reps into
39
+ rounded L/R pills sharing that footprint.
40
+ </Note>
41
+ <ScenarioPair
42
+ view="compact"
43
+ title="Default"
44
+ single={REP_SET}
45
+ left={REP_SET}
46
+ right={REP_SET_LAGGING}
47
+ />
48
+ </Sheet>
49
+ ),
50
+ }
51
+
52
+ export const SetTypes: Story = {
53
+ name: 'Set Types',
54
+ render: () => (
55
+ <Sheet>
56
+ <Note>
57
+ The full set-type vocabulary at resting scale. The chunk-notch still separates drop
58
+ sub-loads even when every bar is the same height.
59
+ </Note>
60
+ <SetTypeBoard view="compact" />
61
+ </Sheet>
62
+ ),
63
+ }
64
+
65
+ export const FatigueDecline: Story = {
66
+ name: 'Fatigue Decline',
67
+ render: () => (
68
+ <Sheet>
69
+ <Note>
70
+ Loss colouring is the default: each bar is coloured by its drop from the set&apos;s own
71
+ best, so a fatiguing set reads green-to-red regardless of absolute speed. At compact scale
72
+ this colour IS the signal — there is no height to read.
73
+ </Note>
74
+ <ScenarioPair
75
+ view="compact"
76
+ title="Fatigue decline"
77
+ single={FATIGUE_SET}
78
+ left={FATIGUE_SET}
79
+ right={FATIGUE_SET_LAGGING}
80
+ barColor="loss"
81
+ />
82
+ </Sheet>
83
+ ),
84
+ }
85
+
86
+ export const EmptyColdBoot: Story = {
87
+ name: 'Empty / Cold Boot',
88
+ render: () => (
89
+ <Sheet>
90
+ <Note>
91
+ Before the first rep lands. A planned set draws its upcoming reps, so the strip holds both
92
+ its footprint AND its column count — nothing reflows when rep one arrives.
93
+ </Note>
94
+ <ScenarioPair
95
+ view="compact"
96
+ title="Cold boot · 6 planned, none logged"
97
+ singleSet={{ type: 'straight', velocities: [], planned: 6 }}
98
+ leftSet={{ type: 'straight', velocities: [], planned: 6 }}
99
+ rightSet={{ type: 'straight', velocities: [], planned: 6 }}
100
+ />
101
+ <ScenarioPair
102
+ view="compact"
103
+ title="Truly empty · no plan to draw"
104
+ note="No planned count either — the only case where there is nothing to hold."
105
+ single={[]}
106
+ left={[]}
107
+ right={[]}
108
+ />
109
+ </Sheet>
110
+ ),
111
+ }
112
+
113
+ export const DualLaggingSide: Story = {
114
+ name: 'Dual · Lagging Side',
115
+ render: () => (
116
+ <Sheet>
117
+ <Note>
118
+ Index-locked: the right slot has logged fewer reps, so its trailing columns render as faint
119
+ empties directly under the left&apos;s. Columns never shift — the next rep a lagging side
120
+ performs lands in its own column.
121
+ </Note>
122
+ <ScenarioPair
123
+ view="compact"
124
+ title="Equal counts · the common case"
125
+ note="Both slots logged the same reps. This is what most of a session looks like."
126
+ single={REP_SET}
127
+ left={REP_SET}
128
+ right={[0.93, 0.88, 0.84, 0.78, 0.7]}
129
+ />
130
+ <ScenarioPair
131
+ view="compact"
132
+ title="Missed rep · lower slot"
133
+ note="Rep 3 missing from the LOWER slot — its column holds as a faint empty."
134
+ single={REP_SET}
135
+ left={REP_SET}
136
+ right={[0.93, 0.88, 0.78, 0.7]}
137
+ />
138
+ <ScenarioPair
139
+ view="compact"
140
+ title="Missed rep · upper slot"
141
+ note="The mirror: the UPPER slot is short a rep."
142
+ single={REP_SET}
143
+ left={[0.95, 0.9, 0.8, 0.72]}
144
+ right={[0.93, 0.88, 0.84, 0.78, 0.7]}
145
+ />
146
+ <ScenarioPair
147
+ view="compact"
148
+ title="One slot well behind"
149
+ single={REP_SET}
150
+ left={REP_SET}
151
+ right={REP_SET_LAGGING.slice(0, 2)}
152
+ />
153
+ </Sheet>
154
+ ),
155
+ }
156
+
157
+ export const Responsive: Story = {
158
+ name: 'Responsive',
159
+ render: () => (
160
+ <Sheet width={520}>
161
+ <Note>
162
+ Compact degrades by WIDTH, not height — the bar layout is shared geometry, so bars and gaps
163
+ scale together and the chunk-notch stays proportional.
164
+ </Note>
165
+ {[440, 300, 180, 110].map((w) => (
166
+ <ScenarioPair
167
+ key={w}
168
+ view="compact"
169
+ title={`${w}px`}
170
+ width={w}
171
+ single={REP_SET}
172
+ left={REP_SET}
173
+ right={REP_SET_LAGGING}
174
+ />
175
+ ))}
176
+ </Sheet>
177
+ ),
178
+ }
179
+
180
+ export const RepTypes: Story = {
181
+ name: 'Rep Types',
182
+ render: () => (
183
+ <Sheet>
184
+ <Note>
185
+ Every state a rep column can be in at resting scale. Colour is the only channel compact has,
186
+ so a state that needs height to read must find another way here.
187
+ </Note>
188
+ <RepTypeBoard view="compact" />
189
+ </Sheet>
190
+ ),
191
+ }
@@ -0,0 +1,186 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { DualVelocityStrip } from './VelocityStrip'
3
+ import {
4
+ LEFT_SLOT,
5
+ RIGHT_SLOT,
6
+ REP_SET,
7
+ REP_SET_LAGGING,
8
+ IN_PROGRESS_SET,
9
+ IN_PROGRESS_LAGGING,
10
+ dualOf,
11
+ wallDecorator,
12
+ } from './velocity-story-kit'
13
+
14
+ /**
15
+ * `DualVelocityStrip` — the strip when the exercise used TWO Voltras.
16
+ *
17
+ * Dual is **not a fourth view**, so its scenario coverage lives in the
18
+ * per-view groups, where every scenario renders single ABOVE dual on one
19
+ * dataset:
20
+ * [Compact](?path=/docs/workout-dataviz-velocitystrip-compact--docs) ·
21
+ * [Expanded](?path=/docs/workout-dataviz-velocitystrip-expanded--docs) ·
22
+ * [Hero](?path=/docs/workout-dataviz-velocitystrip-hero--docs).
23
+ *
24
+ * This group exists for the two things a pairing cannot carry: the exported
25
+ * **API** (`DualVelocityStripProps`, in the args table below) and the layout
26
+ * **extremes** — heights at which the labels have to degrade, and mismatched
27
+ * rep counts that must stay index-locked.
28
+ *
29
+ * The `hero` variant is literally two composed `VelocityStrip` heroes — an
30
+ * `orientation="up"` hero over a mirrored `orientation="down"` one, sharing ONE
31
+ * height scale and meeting at one centre axis — so every hero improvement
32
+ * reaches the dual for free. The up wing grows UP and the down wing grows DOWN,
33
+ * so asymmetry reads pre-attentively as the silhouette: a stronger side reads
34
+ * TALLER, while each wing still colours by its OWN best. **Side is POSITION
35
+ * only, never hue.** Each side takes a `DualVelocityStream` (`velocities` OR a
36
+ * structured `set`, plus an optional `label`); the vertical edge label is DATA,
37
+ * not a hardcoded side. Single-slot sets keep using `VelocityStrip`.
38
+ */
39
+ const meta: Meta<typeof DualVelocityStrip> = {
40
+ title: 'Workout/DataViz/VelocityStrip/Dual',
41
+ component: DualVelocityStrip,
42
+ tags: ['autodocs'],
43
+ decorators: [wallDecorator],
44
+ argTypes: {
45
+ left: {
46
+ control: 'object',
47
+ description:
48
+ 'Up-wing stream — `{ velocities | set, label }`. Edit `label` here to change the slot name.',
49
+ },
50
+ right: {
51
+ control: 'object',
52
+ description:
53
+ 'Down-wing stream — `{ velocities | set, label }`. Edit `label` here to change the slot name.',
54
+ },
55
+ variant: {
56
+ control: 'inline-radio',
57
+ options: ['hero', 'compact', 'rail'],
58
+ description:
59
+ 'hero (wall: labels + reference lines), compact (flat resting fold), or rail (lean, neither)',
60
+ },
61
+ scale: {
62
+ control: 'inline-radio',
63
+ options: ['peak', 'fixed'],
64
+ description:
65
+ 'bar scaling, shared across both wings: peak (pair max) or fixed (cross-set ceiling)',
66
+ },
67
+ barColor: {
68
+ control: 'inline-radio',
69
+ options: ['zone', 'loss'],
70
+ description: 'per-wing loss (default) or the shared absolute zone scale; never encodes side',
71
+ },
72
+ targetReps: {
73
+ control: 'number',
74
+ description:
75
+ 'planned rep count — reps beyond a side’s done count draw as mirrored dashed stubs',
76
+ },
77
+ liveRepIndex: {
78
+ control: 'number',
79
+ description: 'index of the newest rep; that mirrored pair animates in (hero only)',
80
+ },
81
+ height: {
82
+ control: { type: 'number', min: 60, max: 260, step: 4 },
83
+ description: 'total plot height (px), split evenly into the up (L) and down (R) wings',
84
+ },
85
+ zones: { control: false, description: 'Velocity-zone bands (WA); default scale when absent' },
86
+ },
87
+ }
88
+ export default meta
89
+ type Story = StoryObj<typeof DualVelocityStrip>
90
+
91
+ // ── The API surface ─────────────────────────────────────────────────────────
92
+
93
+ /** Controls-driven: flip `variant` / `scale` / `barColor` / `targetReps` / `height`, edit each stream's `label` + data. */
94
+ export const Playground: Story = {
95
+ args: {
96
+ left: dualOf(REP_SET, LEFT_SLOT),
97
+ right: dualOf(REP_SET_LAGGING, RIGHT_SLOT),
98
+ variant: 'hero',
99
+ scale: 'peak',
100
+ height: 200,
101
+ },
102
+ }
103
+
104
+ // ── The two canonical states ────────────────────────────────────────────────
105
+ // Deliberately minimal — the scenario pairs in the view groups carry breadth.
106
+
107
+ /** Both sides done, the lagging side one rep short — the everyday finished dual set. */
108
+ export const Default: Story = {
109
+ args: {
110
+ left: dualOf(REP_SET, LEFT_SLOT),
111
+ right: dualOf(REP_SET_LAGGING, RIGHT_SLOT),
112
+ variant: 'hero',
113
+ scale: 'fixed',
114
+ height: 200,
115
+ },
116
+ }
117
+
118
+ /**
119
+ * Mid-set and live: 4 of 8 planned on the up wing, 3 on the down, newest rep
120
+ * animating in. The remainder draws as mirrored dashed stubs on both wings, so
121
+ * the columns are reserved and the set never reflows as reps land — which is
122
+ * also why a live dual should run `scale="fixed"`.
123
+ */
124
+ export const InProgress: Story = {
125
+ args: {
126
+ left: dualOf(IN_PROGRESS_SET, LEFT_SLOT),
127
+ right: dualOf(IN_PROGRESS_LAGGING, RIGHT_SLOT),
128
+ variant: 'hero',
129
+ scale: 'fixed',
130
+ targetReps: 8,
131
+ liveRepIndex: 3,
132
+ height: 200,
133
+ },
134
+ }
135
+
136
+ // ── Layout extremes ─────────────────────────────────────────────────────────
137
+ // Recovered from the pre-reorg DualVelocityStrip stories. Unit tests cover the
138
+ // behaviour; these cover the SURFACE — label placement and collision at full
139
+ // width is a class of bug the tests have already missed once (TD-07.10).
140
+
141
+ /**
142
+ * Symmetric index-lock — the up wing logs 5 reps, the down wing 3, same
143
+ * structure. The dual renders 5 aligned columns and the down side's reps 4–5
144
+ * render as EMPTY cells directly under the up side's (index-locked, never
145
+ * shifted). The next rep a lagging side performs lands at its own column.
146
+ */
147
+ export const SymmetricEmpties: Story = {
148
+ args: {
149
+ left: dualOf([0.9, 0.88, 0.86, 0.84, 0.82], LEFT_SLOT),
150
+ right: dualOf([0.85, 0.8, 0.75], RIGHT_SLOT),
151
+ variant: 'hero',
152
+ scale: 'fixed',
153
+ },
154
+ }
155
+
156
+ /**
157
+ * Small height (120px) — each wing gets ~59px rather than the full hero
158
+ * sentinel, and the responsive text DEGRADES: the per-side slot names collapse
159
+ * to initials ("Left"/"Right" → "L"/"R") and the VL20 / VL30 band labels drop
160
+ * entirely, while the dashed lines + washes stay.
161
+ */
162
+ export const SmallHeightLabels: Story = {
163
+ args: {
164
+ left: dualOf([0.96, 0.9, 0.83, 0.72], LEFT_SLOT),
165
+ right: dualOf([0.9, 0.82, 0.71, 0.6], RIGHT_SLOT),
166
+ variant: 'hero',
167
+ scale: 'fixed',
168
+ height: 120,
169
+ },
170
+ }
171
+
172
+ /**
173
+ * Very small height (72px) — the floor of graceful degradation. Below the
174
+ * thresholds the VL20 / VL30 labels are gone and the slot names are down to
175
+ * initials (a multi-word name would show its initials, e.g. "L A"). No
176
+ * shrinking into an illegible smear.
177
+ */
178
+ export const TinyHeight: Story = {
179
+ args: {
180
+ left: dualOf([0.96, 0.9, 0.83, 0.72], LEFT_SLOT),
181
+ right: dualOf([0.9, 0.82, 0.71, 0.6], RIGHT_SLOT),
182
+ variant: 'hero',
183
+ scale: 'fixed',
184
+ height: 72,
185
+ },
186
+ }