@titan-design/react-ui 0.9.3 → 0.11.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 (120) hide show
  1. package/dist/bodymap.js +78 -24
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +78 -24
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +429 -11
  6. package/dist/index.d.ts +429 -11
  7. package/dist/index.js +2386 -885
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2306 -836
  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 +1900 -1609
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1901 -1610
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +129 -34
  20. package/dist/theme/index.d.ts +129 -34
  21. package/dist/theme/index.js +95 -19
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +92 -20
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +88 -24
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +88 -24
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  31. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  32. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  33. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  34. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  35. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  36. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  37. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  38. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  39. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  40. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  41. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  42. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  43. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  45. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  46. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  47. package/src/components/custom/Fatigue/README.md +93 -0
  48. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  49. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  51. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  52. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  53. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  54. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  55. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  56. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  57. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  58. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  59. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  60. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  61. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  62. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  63. package/src/components/custom/Fatigue/index.ts +41 -0
  64. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  65. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  66. package/src/components/custom/Workout/BaseBadge.tsx +5 -5
  67. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  68. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  69. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  71. package/src/components/custom/Workout/README.md +62 -16
  72. package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
  73. package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
  74. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  75. package/src/components/custom/Workout/SessionHeader.tsx +2 -2
  76. package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
  77. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  78. package/src/components/custom/Workout/SessionRail.tsx +55 -18
  79. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  80. package/src/components/custom/Workout/SetRow.tsx +9 -2
  81. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
  82. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
  83. package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
  84. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  85. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  86. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  87. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  88. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  89. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  90. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  91. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
  92. package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
  93. package/src/components/custom/Workout/index.ts +3 -0
  94. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  95. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  96. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  97. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  98. package/src/components/custom/index.ts +1 -0
  99. package/src/components/ui/surface/Surface.stories.tsx +195 -1
  100. package/src/components/ui/surface/Surface.test.tsx +148 -9
  101. package/src/components/ui/surface/Surface.tsx +37 -9
  102. package/src/components/ui/surface/SurfaceContext.ts +48 -8
  103. package/src/components/ui/surface/index.ts +1 -0
  104. package/src/components/ui/surface/surface.contract.test.ts +283 -0
  105. package/src/test/raw-color-patterns.test.ts +71 -0
  106. package/src/theme/ColorPalettes.stories.tsx +871 -0
  107. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  108. package/src/theme/bar-paper.ts +59 -0
  109. package/src/theme/color-stories.coverage.test.ts +130 -0
  110. package/src/theme/color-story-kit.tsx +170 -0
  111. package/src/theme/config.ts +12 -0
  112. package/src/theme/elevation.ts +90 -67
  113. package/src/theme/global.css +35 -9
  114. package/src/theme/tokens/primitives.ts +40 -0
  115. package/src/theme/tokens/semantic.ts +63 -14
  116. package/tailwind.config.js +8 -0
  117. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  118. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  119. package/src/theme/ColorSystem.stories.tsx +0 -323
  120. package/src/theme/Colors.stories.tsx +0 -323
@@ -49,7 +49,7 @@ describe('ExerciseCard', () => {
49
49
  expect(screen.getByTestId('pr-badge-star')).toBeInTheDocument()
50
50
  })
51
51
 
52
- it('renders mini velocity strips for logged sets', () => {
52
+ it('renders compact velocity strips for logged sets', () => {
53
53
  render(
54
54
  <ExerciseCard
55
55
  {...baseCollapsedProps}
@@ -158,10 +158,10 @@ describe('ExerciseCard', () => {
158
158
  expect(within(rows[2]).getByText('3')).toHaveStyle({ color: T_MUTED })
159
159
  })
160
160
 
161
- it('uses the compact velocity-height spotlight for the live set and mini elsewhere', () => {
161
+ it('uses the velocity-height spotlight for the live set and the flat compact strip elsewhere', () => {
162
162
  render(<ExerciseCard {...expandedProps} />)
163
- expect(screen.getAllByTestId('velocity-strip-compact')).toHaveLength(1)
164
- expect(screen.getAllByTestId('velocity-strip-mini')).toHaveLength(2)
163
+ expect(screen.getAllByTestId('velocity-strip-spotlight')).toHaveLength(1)
164
+ expect(screen.getAllByTestId('velocity-strip-compact')).toHaveLength(2)
165
165
  })
166
166
 
167
167
  it('renders the TempoDisplay in the header when tempo provided', () => {
@@ -194,7 +194,9 @@ function CollapsedCard({
194
194
  key={i}
195
195
  velocities={velocities}
196
196
  zones={velocityZones}
197
- variant="mini"
197
+ variant="compact"
198
+ height={8}
199
+ hideBaseline
198
200
  testID={`exercise-card-velocity-strip-${i}`}
199
201
  />
200
202
  ))}
@@ -386,7 +386,13 @@ function VbtBreakdown({
386
386
  {set.label}
387
387
  </Text>
388
388
  <View className="flex-1" style={{ height: 8, justifyContent: 'center' }}>
389
- <VelocityStrip velocities={set.velocities} zones={zones} variant="mini" />
389
+ <VelocityStrip
390
+ velocities={set.velocities}
391
+ zones={zones}
392
+ variant="compact"
393
+ height={8}
394
+ hideBaseline
395
+ />
390
396
  </View>
391
397
  <Text
392
398
  className="text-text-tertiary"
@@ -13,8 +13,8 @@ DeviationBar · IntensityBar · WorkoutPill · MuscleGroupChip · Sparkline ·
13
13
  SupersetWrapper · InputBar · MetricCell · SetsRepsLoad · ExerciseIndicator · SetBar
14
14
 
15
15
  **Molecules** — compose atoms, own a little local state:
16
- VelocityStrip · SetRow · TempoDisplay · RestTimer · MesoProgressBar · WeekRow ·
17
- WorkoutCard · SetStrip · ExerciseHeading · ExerciseCardHeading
16
+ VelocityStrip · DualVelocityStrip · SetRow · TempoDisplay · RestTimer · MesoProgressBar ·
17
+ WeekRow · WorkoutCard · SetStrip · ExerciseHeading · ExerciseCardHeading
18
18
 
19
19
  **Organisms** — full features, often with their own data contract:
20
20
  ExerciseCard · SessionRail · MesoCard · MesoStatusCard · PrHistoryModal ·
@@ -66,11 +66,12 @@ type props without pulling the dependency.
66
66
  needs the same yellow/green/red "time-to-target" tone (a coach card, a rep-tempo
67
67
  summary). It is pure logic, trivially portable.
68
68
  3. **`LiveTempoRow` → a `TempoLiveRow` molecule** — only if a consumer wants the running
69
- row *without* the chip chrome (label/background/padding). Until then the chrome and the
69
+ row _without_ the chip chrome (label/background/padding). Until then the chrome and the
70
70
  row belong together as one component with a `live` prop, not two.
71
71
 
72
72
  Not worth splitting today (speculative extraction the workflow warns against); this note
73
73
  is the trigger list for when it stops being speculative.
74
+
74
75
  - **S3 session-rail family** — `SessionRail` (organism) composes the standalone
75
76
  `ExerciseCardHeading` molecule per exercise, which composes an `ExerciseHeading`
76
77
  info block + a `SetStrip`:
@@ -96,7 +97,7 @@ type props without pulling the dependency.
96
97
  vs. variable-todo cyan), `drop` (one set, sub-loads split by 2px notches), `myo`
97
98
  (done rest-pause — activation + clusters split by 3px cluster gaps), and
98
99
  `myo-upcoming` (planned myo, length unknown — grey activation + a fading, right-open
99
- cyan "clusters-to-failure" trail). The chunk-size *pattern* carries set-type identity:
100
+ cyan "clusters-to-failure" trail). The chunk-size _pattern_ carries set-type identity:
100
101
  **butted reps (0) < notch (2px) < cluster gap (3px) < set gap (5px)**. `cyan-900`
101
102
  (`SET_STRIP_VARIABLE_COLOR`) is the shared "variable / unknown / opportunity" pin.
102
103
  `SegmentedBar` carries these via additive `leadingGap` (per-segment left margin) and
@@ -113,6 +114,14 @@ type props without pulling the dependency.
113
114
  **Composes** link down the tree — matching the S1/S2 shell families. (The component
114
115
  files stay flat on disk in `custom/Workout/`; only the story `title`s build the tree.)
115
116
 
117
+ - **Dual at the RAIL level — rejected, removed** — `DualSessionRail` (two
118
+ `SessionRail` columns side by side) was explored and rejected: bilateral
119
+ asymmetry is a property of how a SET was performed, not of the session's
120
+ structure, so the rail stays single and consistent and the dual distinction is
121
+ surfaced per-set/per-rep instead (`DualVelocityStrip`). Deleted 2026-07-26 —
122
+ see `packages/ui/REJECTED.md` for the full reasoning. Do not reintroduce a
123
+ rail-level split without reading it first.
124
+
116
125
  - **VelocityStrip set-type modes (`set` prop)** — beyond the flat `velocities`
117
126
  array (unchanged, still the source of truth for `SetRow` / `ExerciseCard`), the
118
127
  strip accepts an optional structured `VelocitySet` descriptor and renders the
@@ -133,7 +142,7 @@ type props without pulling the dependency.
133
142
  active-set spotlight (velocity-height done reps + short grey stubs) and renders
134
143
  the advanced types as a short mini-style encoding. Every modality is documented
135
144
  with a copy-paste `set` config in **`Workout/DataViz/VelocityStrip/Set
136
- Modalities`** (each card carries a `Collapse` accordion; promoted from the
145
+ Modalities`** (each card carries a `Collapse` accordion; promoted from the
137
146
  now-deleted `S3SetModalities` Lab specimen).
138
147
  - **VelocityStrip `hero` variant** — the across-the-room, single-set **wall**
139
148
  treatment (the north-star live page's velocity hero). Tall bars (default 220px
@@ -147,12 +156,49 @@ type props without pulling the dependency.
147
156
  left-packed) with a caller-set fixed height; the eyebrow/section title is
148
157
  organism chrome, not part of the primitive. **Live-update model:** the plan's
149
158
  slots are pre-allocated (`max(done, target)` columns), so a landing rep converts
150
- placeholder→bar in the *same* slot with a pop — no reflow within the plan; pair
159
+ placeholder→bar in the _same_ slot with a pop — no reflow within the plan; pair
151
160
  with `scale="fixed"` so heights never rescale either. The one reflow case is
152
161
  set-expansion **beyond** `targetReps` (AMRAP overflow adds a column and flex-
153
162
  narrows the rest — currently snaps; smooth overflow reflow is a deferred
154
163
  follow-up). Documented by the `HeroPlayground` / `Hero*` stories on the wall
155
164
  background.
165
+ - **DualVelocityStrip** (molecule) — the two-device (LEFT + RIGHT voltra) **diverging**
166
+ wall/rail chart. `composes ↓` the `VelocityStrip` machinery in the same file
167
+ (`buildSlots`/`VelocitySlot` slot model, the `makeBarColorFor` zone scale, the hero
168
+ geometry constants, the shared `useLiveRepPop` entrance, and the extracted
169
+ `DashedReferenceLine`) rather than restating it. `used-by ↑` the north-star dual-voltra
170
+ live page (organism chrome). ONE diverging chart shares a horizontal centre axis: LEFT
171
+ reps grow **up**, RIGHT reps grow **down**, one mirrored pair per rep index, so the L/R
172
+ asymmetry reads pre-attentively as the silhouette. **Side is POSITION only, never hue** —
173
+ both wings colour reps by velocity zone through the same resolver as the single strip.
174
+ Each side takes a `DualVelocityStream` (`velocities` OR a structured `set` — the same
175
+ shapes `VelocityStrip` accepts — plus an optional `label`). The **vertical edge label is
176
+ data**: each side renders its `DualVelocityStream.label` (a slot name, e.g. "Left Arm"),
177
+ NOT a hardcoded LEFT/RIGHT — a side with no `label` (or an empty one) renders no tag, and
178
+ when neither side carries one the gutter is omitted entirely. The label keeps the prior
179
+ vertical (rotated) orientation so it never overlaps the bars. Two scales: `hero` (tall
180
+ wings, per-rep m/s velocity labels, a dashed running-best reference line per side) and
181
+ `rail` (compact — no velocity labels / reference lines, slot names in a narrow gutter).
182
+ **Rep-index alignment is the invariant:** column *i* is rep *i* on
183
+ both sides, so the set-type slot *kinds* carry through (rep / todo / variable / continue,
184
+ coloured as in the single strip) but the wide-notch chunk **gaps** (drop / myo / cluster
185
+ boundaries) are intentionally NOT rendered — per-side horizontal gaps would break the
186
+ mirrored L↔R column alignment. Single-voltra sets keep using `VelocityStrip`
187
+ `variant="hero"`. Documented by the `Playground` / `Hero*` / `Rail` stories on the wall
188
+ background (`Workout/DataViz/DualVelocityStrip`).
189
+
190
+ **Reuse audit — `DashedReferenceLine` (in-file today, top-level follow-up).** The dashed
191
+ running-best line was hand-rolled three times inside `VelocityStrip.tsx` (the single
192
+ `hero`, plus the dual's L and R wings); it is now one in-file `DashedReferenceLine`
193
+ (`anchor: 'top' | 'bottom'`, pixel `offset`, `testID`) those three sites share. A FOURTH
194
+ consumer exists across files — `Sparkline`'s `referenceLines` prop renders the same dashed
195
+ overlay (with an added opacity, an optional label, and a data-domain→Y projection), and two
196
+ Lab specimens duplicate it again. Promoting `DashedReferenceLine` to a **top-level
197
+ `ReferenceLine` overlay primitive** and migrating `Sparkline` (keeping its label/opacity
198
+ options as props) is the ≥2-consumer extraction the DoD favours — deliberately deferred to
199
+ a follow-up so the hero PR stays focused (the cross-file migration touches `Sparkline`'s
200
+ test surface and the Lab specimens, which are out of this branch's scope). Proposed API:
201
+ `<ReferenceLine anchor offset color dashed opacity? label? testID />`.
156
202
  - **RestTimer `ring` variant** — the across-the-room **wall** rest treatment (the
157
203
  north-star rest page). Built as a **three-tier decomposition** (not a one-off):
158
204
  `CircularProgress` (atom, gained a **`children`** center slot + a **`fill`**
@@ -174,7 +220,7 @@ type props without pulling the dependency.
174
220
  dashboard scale, added as the idiomatic titan `size` union (a JS number-map per
175
221
  component; **`default` values are byte-identical** to before, so existing consumers are
176
222
  untouched). **ZoneTrack** (the shared gauge primitive) gained `size` that scales track,
177
- needle, tick lines and tick labels together; its *other* consumers (TrainingLoadGauge,
223
+ needle, tick lines and tick labels together; its _other_ consumers (TrainingLoadGauge,
178
224
  RpeCalibration) default to `default` and are unaffected. **FatigueMeter** passes `size`
179
225
  through and lets `trackHeight` flow from it. `Wall*` / `WallDensity` stories on the wall
180
226
  background. (**TempoBar** was retired here — the standalone active-tempo bar is superseded
@@ -187,17 +233,17 @@ type props without pulling the dependency.
187
233
  per exercise, six distinct kinds over four tier colors (bound to titan status
188
234
  tokens, read as literal hex via `getSemanticColors('dark')`, not `resolveColor`,
189
235
  so tests can assert them). Glyph = the specific signal; color = the severity tier. Chips are
190
- outlined (border + glyph, no fill) so they never collide with the *filled*
236
+ outlined (border + glyph, no fill) so they never collide with the _filled_
191
237
  velocity strip, and static (no pulse — they are chrome). Precedence, high → low:
192
238
 
193
- | # | kind | tier | glyph (mirrors lucide) |
194
- |---|------|------|------------------------|
195
- | 1 | `imbalance` | danger (`status-error`) | `Scale` |
196
- | 2 | `overshoot` | danger (`status-error`) | `AlertTriangle` |
197
- | 3 | `velocity-loss` | warning (`status-warning`) | `TrendingDown` |
198
- | 4 | `missed-reps` | warning (`status-warning`) | `CircleSlash` |
199
- | 5 | `pr` | success (`status-success`) | `Award` |
200
- | 6 | `info` | info (`status-info`) | `Info` |
239
+ | # | kind | tier | glyph (mirrors lucide) |
240
+ | --- | --------------- | -------------------------- | ---------------------- |
241
+ | 1 | `imbalance` | danger (`status-error`) | `Scale` |
242
+ | 2 | `overshoot` | danger (`status-error`) | `AlertTriangle` |
243
+ | 3 | `velocity-loss` | warning (`status-warning`) | `TrendingDown` |
244
+ | 4 | `missed-reps` | warning (`status-warning`) | `CircleSlash` |
245
+ | 5 | `pr` | success (`status-success`) | `Award` |
246
+ | 6 | `info` | info (`status-info`) | `Info` |
201
247
 
202
248
  `resolveIndicator(candidates)` collapses the active signals to the single
203
249
  highest-precedence kind to show (`undefined` when none). Alerts outrank `pr`. The
@@ -22,6 +22,9 @@ import { WeightBadge } from './WeightBadge'
22
22
  import { PrBadge } from './PrBadge'
23
23
  import { PlaceholderStrip } from './PlaceholderStrip'
24
24
  import { SupersetWrapper } from './SupersetWrapper'
25
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
26
+
27
+ const t = getSemanticColors('dark')
25
28
 
26
29
  // ----------------------------------------------------------------------------- gallery scaffold
27
30
  class Boundary extends Component<{ children: ReactNode }, { err: string | null }> {
@@ -364,10 +367,10 @@ export const AllRows: Story = {
364
367
  </Cell>
365
368
  <Cell name="SupersetWrapper" entity="grouping bracket" expand="no">
366
369
  <SupersetWrapper label="SS1" color="#FF7900">
367
- <View style={{ padding: 10, backgroundColor: '#1C1C1C', borderRadius: 8 }}>
370
+ <View style={{ padding: 10, backgroundColor: t['surface-raised'], borderRadius: 8 }}>
368
371
  <Text style={{ color: '#F3F4F6' }}>Exercise A</Text>
369
372
  </View>
370
- <View style={{ padding: 10, backgroundColor: '#1C1C1C', borderRadius: 8 }}>
373
+ <View style={{ padding: 10, backgroundColor: t['surface-raised'], borderRadius: 8 }}>
371
374
  <Text style={{ color: '#F3F4F6' }}>Exercise B</Text>
372
375
  </View>
373
376
  </SupersetWrapper>
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import type { Meta, StoryObj } from '@storybook/react-vite'
6
6
  import { NavStub, Rail, Page, T_TERTIARY } from './setHeadingKit'
7
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
8
+
9
+ const t = getSemanticColors('dark')
7
10
 
8
11
  type Args = { stripHeight: number }
9
12
  const meta: Meta<Args> = {
@@ -25,7 +28,16 @@ export const NextToNav: Story = {
25
28
  <div style={{ display: 'flex', alignItems: 'stretch', width: 'fit-content' }}>
26
29
  <NavStub />
27
30
  <Rail stripH={args.stripHeight} />
28
- <div style={{ width: 140, background: '#161616', alignSelf: 'stretch', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
31
+ <div
32
+ style={{
33
+ width: 140,
34
+ background: t['background-base'],
35
+ alignSelf: 'stretch',
36
+ display: 'flex',
37
+ alignItems: 'center',
38
+ justifyContent: 'center',
39
+ }}
40
+ >
29
41
  <span style={{ fontSize: 10, color: T_TERTIARY, fontFamily: 'monospace' }}>stage</span>
30
42
  </div>
31
43
  </div>
@@ -56,7 +56,7 @@ export const Weighted: Story = {
56
56
  args: {
57
57
  height: 12,
58
58
  gap: 4,
59
- radius: 6,
59
+ radius: 2,
60
60
  segments: [
61
61
  { color: '#2ED573', weight: 3 },
62
62
  { color: '#F9B415', weight: 2 },
@@ -72,7 +72,7 @@ export const PartialFills: Story = {
72
72
  args: {
73
73
  height: 12,
74
74
  gap: 4,
75
- radius: 6,
75
+ radius: 2,
76
76
  segments: [
77
77
  { color: '#2ED573', fill: 1 },
78
78
  { color: '#F9B415', fill: 0.6 },
@@ -89,7 +89,7 @@ export const PartialFills: Story = {
89
89
  export const WithMarker: Story = {
90
90
  args: {
91
91
  height: 10,
92
- radius: 5,
92
+ radius: 2,
93
93
  segments: [{ color: '#01B5D1', fill: 0.7 }],
94
94
  marker: { position: 0.5, color: '#FFFFFF' },
95
95
  },
@@ -8,8 +8,8 @@ import { MetricTiles, type MetricTileData } from './MetricTiles'
8
8
  import { ScheduleTiles } from './ScheduleTiles'
9
9
  import { paceTone, paceToneColor } from './paceTone'
10
10
 
11
- // The header shares the SideNav's `background-base` (charcoal 900, #101010) so the nav and
12
- // the rail header read as ONE continuous dark plane on the left — the sunk exercise list
11
+ // The header shares the SideNav's `background-base` (warm-tapered ramp shell, #1C1916) so the
12
+ // nav and the rail header read as ONE continuous dark plane on the left — the sunk exercise list
13
13
  // sits raised off it. A `<Surface level="background">` owns that plane so the header no
14
14
  // longer hand-sets it, and its title/label text resolve on-surface colours from context.
15
15
 
@@ -1,8 +1,11 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View, Text } from 'react-native'
3
3
  import { primitiveRamps } from '../../../theme/tokens/primitives'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
  import { SessionRail, type SessionRailExercise } from './SessionRail'
5
6
 
7
+ const t = getSemanticColors('dark')
8
+
6
9
  /**
7
10
  * `SessionRail` (shell organism) — the live-workout exercise list: a flat raised
8
11
  * `SessionHeader` glance (title, stat tiles, chunked pace bar) over a sunk, inset list
@@ -20,7 +23,7 @@ const meta: Meta<typeof SessionRail> = {
20
23
  },
21
24
  decorators: [
22
25
  (Story) => (
23
- <View className="min-h-screen flex-row" style={{ backgroundColor: '#101010' }}>
26
+ <View className="min-h-screen flex-row" style={{ backgroundColor: t['background-base'] }}>
24
27
  <Story />
25
28
  <View className="flex-1 items-center justify-center">
26
29
  <Text style={{ color: '#6B7280', fontSize: 12 }}>main viewport</Text>
@@ -76,6 +76,49 @@ describe('SessionRail', () => {
76
76
  expect(onExercisePress).toHaveBeenCalledWith(exercises[1], 1)
77
77
  })
78
78
 
79
+ describe('expandedIndex', () => {
80
+ const withSets: SessionRailExercise[] = exercises.map((ex, i) =>
81
+ i === 1
82
+ ? {
83
+ ...ex,
84
+ sets: [
85
+ {
86
+ state: 'done' as const,
87
+ setNumber: 1,
88
+ unit: 'lbs' as const,
89
+ reps: 10,
90
+ weight: 90,
91
+ velocities: [0.72, 0.66],
92
+ },
93
+ ],
94
+ }
95
+ : ex
96
+ )
97
+
98
+ it('renders the named exercise expanded in place, leaving the others collapsed', () => {
99
+ render(<SessionRail {...baseProps} exercises={withSets} expandedIndex={1} />)
100
+ // The expanded row reveals the set table; the collapsed ones do not.
101
+ expect(screen.getByText('SET')).toBeInTheDocument()
102
+ expect(screen.getByText('RPE')).toBeInTheDocument()
103
+ // Every exercise still has a row.
104
+ expect(screen.getByText('Seated Cable Row')).toBeInTheDocument()
105
+ expect(screen.getByText('Cable Chest Press')).toBeInTheDocument()
106
+ expect(screen.getByText('Face Pull')).toBeInTheDocument()
107
+ })
108
+
109
+ it('stays collapsed when the named exercise has no sets', () => {
110
+ // Guards the real case: an exercise whose sets have not loaded yet must not
111
+ // expand into an empty table.
112
+ render(<SessionRail {...baseProps} expandedIndex={1} />)
113
+ expect(screen.queryByText('SET')).not.toBeInTheDocument()
114
+ })
115
+
116
+ it('leaves every row collapsed when expandedIndex is omitted', () => {
117
+ render(<SessionRail {...baseProps} exercises={withSets} />)
118
+ expect(screen.queryByText('SET')).not.toBeInTheDocument()
119
+ })
120
+ })
121
+
79
122
  describe('accessibility', () => {
80
123
  it('has no accessibility violations', async () => {
81
124
  const { container } = render(<SessionRail {...baseProps} />)
@@ -4,18 +4,20 @@ import { primitiveColors } from '../../../theme/tokens/primitives'
4
4
  import { neumorphicShadows } from '../../../theme/shadows'
5
5
  import { Surface } from '../../ui/surface'
6
6
  import { ExerciseCardHeading } from './ExerciseCardHeading'
7
+ import { ExerciseCard } from './ExerciseCard'
8
+ import type { SetRowProps } from './SetRow'
7
9
  import { SessionHeader } from './SessionHeader'
8
10
  import type { MetricTileData } from './MetricTiles'
9
11
  import type { SetStripSet } from './SetStrip'
10
12
  import type { ExerciseIndicatorKind } from './ExerciseIndicator'
11
13
 
12
- // Charcoal-ramp surfaces (the locked S3 shell shades): the nav + the heading plane read as
13
- // ONE dark plane (charcoal 900, #101010, owned by SessionHeader's `<Surface level="background">`).
14
- // The exercise list is a LIGHTER inset panel — `<Surface level="elevated">` (charcoal 600,
15
- // #191919) — recessed via its inner shadow yet paler than the header, so the transparent
14
+ // Warm-tapered ramp surfaces (the locked S3 shell shades): the nav + the heading plane read as
15
+ // ONE dark plane (background shell, #1C1916, owned by SessionHeader's `<Surface level="background">`).
16
+ // The exercise list is a LIGHTER inset panel — `<Surface level="elevated">` (#2A2827)
17
+ // recessed via its inner shadow yet paler than the header, so the transparent
16
18
  // exercise headings on it never blend into the header plane. Surface owns both backgrounds,
17
19
  // so the rail no longer hand-sets them.
18
- const DIVIDER = primitiveColors.charcoal[300] // #2C2C2C — row divider (raised to read on #191919)
20
+ const DIVIDER = primitiveColors.charcoal[300] // #2C2C2C — row divider (raised to read on surface-elevated #2A2827)
19
21
 
20
22
  const DEFAULT_WIDTH = 246
21
23
 
@@ -38,6 +40,12 @@ export interface SessionRailExercise {
38
40
  setStates: SetStripSet[]
39
41
  /** Dim the row as a not-yet-reached exercise. */
40
42
  upcoming?: boolean
43
+ /**
44
+ * Per-set rows for the expanded body. Only read when this row is the one named
45
+ * by `expandedIndex` — a rail row without them can never expand, which is the
46
+ * correct behaviour for an exercise whose sets aren't loaded.
47
+ */
48
+ sets?: SetRowProps[]
41
49
  }
42
50
 
43
51
  export interface SessionRailProps extends ViewProps {
@@ -60,6 +68,16 @@ export interface SessionRailProps extends ViewProps {
60
68
  stripHeight?: number
61
69
  /** Rail width in px. Default 246. */
62
70
  width?: number
71
+ /**
72
+ * Index of the exercise to render EXPANDED in place — its set table opens
73
+ * inside the rail rather than in a separate pane. The row still draws the same
74
+ * `ExerciseCardHeading` (an `ExerciseCard` composes one), so an expanded row
75
+ * differs from a collapsed one only by the revealed body.
76
+ *
77
+ * Ignored when the named exercise has no `sets`. Omit for an all-collapsed
78
+ * rail, which is the prior behaviour.
79
+ */
80
+ expandedIndex?: number
63
81
  onExercisePress?: (exercise: SessionRailExercise, index: number) => void
64
82
  className?: string
65
83
  }
@@ -81,6 +99,7 @@ export function SessionRail({
81
99
  next,
82
100
  stripHeight = 8,
83
101
  width = DEFAULT_WIDTH,
102
+ expandedIndex,
84
103
  onExercisePress,
85
104
  className,
86
105
  style,
@@ -118,19 +137,37 @@ export function SessionRail({
118
137
  borderBottomColor: DIVIDER,
119
138
  }}
120
139
  >
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
- />
140
+ {i === expandedIndex && ex.sets ? (
141
+ <ExerciseCard
142
+ name={ex.name}
143
+ expanded
144
+ summary={{
145
+ sets: ex.summary.sets,
146
+ reps: ex.summary.reps,
147
+ // ExerciseCard's summary takes a numeric load; a string load is
148
+ // an unset/discovery weight, which has no expanded form yet.
149
+ weight: typeof ex.summary.weight === 'number' ? ex.summary.weight : 0,
150
+ unit: ex.summary.unit,
151
+ }}
152
+ tempo={ex.tempo}
153
+ sets={ex.sets}
154
+ onExpandedChange={() => onExercisePress?.(ex, i)}
155
+ />
156
+ ) : (
157
+ <ExerciseCardHeading
158
+ name={ex.name}
159
+ sets={ex.summary.sets}
160
+ reps={ex.summary.reps}
161
+ load={ex.summary.weight}
162
+ unit={ex.summary.unit}
163
+ tempo={ex.tempo}
164
+ indicator={ex.indicator}
165
+ setStates={ex.setStates}
166
+ stripHeight={stripHeight}
167
+ dimmed={ex.upcoming}
168
+ onPress={() => onExercisePress?.(ex, i)}
169
+ />
170
+ )}
134
171
  </View>
135
172
  ))}
136
173
  </Surface>
@@ -55,9 +55,9 @@ describe('SetRow', () => {
55
55
  expect(screen.getByText('8')).toHaveStyle({ color: T_MUTED })
56
56
  })
57
57
 
58
- it('renders a flat mini strip (done reps, no todo stubs)', () => {
58
+ it('renders the flat compact strip (done reps, no todo stubs)', () => {
59
59
  render(<SetRow {...doneRow} />)
60
- expect(screen.getByTestId('velocity-strip-mini')).toBeInTheDocument()
60
+ expect(screen.getByTestId('velocity-strip-compact')).toBeInTheDocument()
61
61
  expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(3)
62
62
  expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
63
63
  })
@@ -76,9 +76,9 @@ describe('SetRow', () => {
76
76
  expect(screen.getByText('2')).toHaveStyle({ color: T_ACTIVE })
77
77
  })
78
78
 
79
- it('renders the compact velocity-height spotlight strip', () => {
79
+ it('renders the velocity-height spotlight strip', () => {
80
80
  render(<SetRow {...liveRow} />)
81
- expect(screen.getByTestId('velocity-strip-compact')).toBeInTheDocument()
81
+ expect(screen.getByTestId('velocity-strip-spotlight')).toBeInTheDocument()
82
82
  // 2 performed reps + grey stubs to the target (10).
83
83
  expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
84
84
  expect(screen.getAllByTestId('velocity-slot-todo')).toHaveLength(8)
@@ -93,10 +93,10 @@ describe('SetRow', () => {
93
93
  expect(screen.getByTestId('set-row-rpe')).toHaveTextContent('—')
94
94
  })
95
95
 
96
- it('is muted and renders an all-grey mini stub strip', () => {
96
+ it('is muted and renders an all-grey compact stub strip', () => {
97
97
  render(<SetRow {...todoRow} />)
98
98
  expect(screen.getByText('3')).toHaveStyle({ color: T_MUTED })
99
- expect(screen.getByTestId('velocity-strip-mini')).toBeInTheDocument()
99
+ expect(screen.getByTestId('velocity-strip-compact')).toBeInTheDocument()
100
100
  expect(screen.getAllByTestId('velocity-slot-todo')).toHaveLength(10)
101
101
  expect(screen.queryByTestId(/^velocity-bar-\d+$/)).not.toBeInTheDocument()
102
102
  })
@@ -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
  />
@@ -2,6 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View, Text } from 'react-native'
3
3
  import { SupersetWrapper } from './SupersetWrapper'
4
4
  import { ExerciseCard } from './ExerciseCard'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+
7
+ const t = getSemanticColors('dark')
5
8
 
6
9
  const meta: Meta<typeof SupersetWrapper> = {
7
10
  title: 'Workout/SupersetWrapper',
@@ -9,7 +12,7 @@ const meta: Meta<typeof SupersetWrapper> = {
9
12
  tags: ['autodocs'],
10
13
  decorators: [
11
14
  (Story) => (
12
- <View style={{ maxWidth: 400, padding: 16, backgroundColor: '#121212' }}>
15
+ <View style={{ maxWidth: 400, padding: 16, backgroundColor: t['background-base'] }}>
13
16
  <Story />
14
17
  </View>
15
18
  ),
@@ -26,24 +29,20 @@ export const Default: Story = {
26
29
  <View
27
30
  style={{
28
31
  padding: 12,
29
- backgroundColor: '#1C1C1C',
32
+ backgroundColor: t['surface-raised'],
30
33
  borderRadius: 8,
31
34
  }}
32
35
  >
33
- <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>
34
- Exercise A
35
- </Text>
36
+ <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>Exercise A</Text>
36
37
  </View>
37
38
  <View
38
39
  style={{
39
40
  padding: 12,
40
- backgroundColor: '#1C1C1C',
41
+ backgroundColor: t['surface-raised'],
41
42
  borderRadius: 8,
42
43
  }}
43
44
  >
44
- <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>
45
- Exercise B
46
- </Text>
45
+ <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>Exercise B</Text>
47
46
  </View>
48
47
  </>
49
48
  ),
@@ -58,24 +57,20 @@ export const CustomLabel: Story = {
58
57
  <View
59
58
  style={{
60
59
  padding: 12,
61
- backgroundColor: '#1C1C1C',
60
+ backgroundColor: t['surface-raised'],
62
61
  borderRadius: 8,
63
62
  }}
64
63
  >
65
- <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>
66
- Exercise A1
67
- </Text>
64
+ <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>Exercise A1</Text>
68
65
  </View>
69
66
  <View
70
67
  style={{
71
68
  padding: 12,
72
- backgroundColor: '#1C1C1C',
69
+ backgroundColor: t['surface-raised'],
73
70
  borderRadius: 8,
74
71
  }}
75
72
  >
76
- <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>
77
- Exercise A2
78
- </Text>
73
+ <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>Exercise A2</Text>
79
74
  </View>
80
75
  </>
81
76
  ),
@@ -90,24 +85,20 @@ export const CustomColor: Story = {
90
85
  <View
91
86
  style={{
92
87
  padding: 12,
93
- backgroundColor: '#1C1C1C',
88
+ backgroundColor: t['surface-raised'],
94
89
  borderRadius: 8,
95
90
  }}
96
91
  >
97
- <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>
98
- Exercise A
99
- </Text>
92
+ <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>Exercise A</Text>
100
93
  </View>
101
94
  <View
102
95
  style={{
103
96
  padding: 12,
104
- backgroundColor: '#1C1C1C',
97
+ backgroundColor: t['surface-raised'],
105
98
  borderRadius: 8,
106
99
  }}
107
100
  >
108
- <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>
109
- Exercise B
110
- </Text>
101
+ <Text style={{ color: '#F3F4F6', fontFamily: 'Inter, sans-serif' }}>Exercise B</Text>
111
102
  </View>
112
103
  </>
113
104
  ),