@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
@@ -1,19 +1,19 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useEffect, useState } from 'react'
3
- import {
4
- View,
5
- Text,
6
- Pressable,
7
- Animated,
8
- Easing,
9
- type ViewProps,
10
- type ViewStyle,
11
- type LayoutChangeEvent,
12
- } from 'react-native'
3
+ import { View, Text, Pressable, Animated, type ViewProps, type ViewStyle } from 'react-native'
13
4
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
14
- import { primitiveColors, primitiveRamps, sequentialEffort } from '../../../theme/tokens/primitives'
5
+ import { primitiveColors, sequentialEffort } from '../../../theme/tokens/primitives'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+ import { alpha } from '../../../utils/colors'
15
8
  import { formatVelocity } from '../../../utils/workout-format'
16
- import { SET_STRIP_VARIABLE_COLOR } from './SetStrip'
9
+ import {
10
+ SetBarChart,
11
+ type SetSlot,
12
+ type SetBarGeometry,
13
+ ChartSideRail,
14
+ SET_BAR_DEFAULT_HEIGHT,
15
+ } from '../charts/SetBarChart'
16
+ import { ANIMATION_EASING } from '../charts/live-rep-growth'
17
17
 
18
18
  /**
19
19
  * Structural velocity-zone band accepted from an upstream analytics source
@@ -66,10 +66,63 @@ export interface VelocityStripProps extends ViewProps {
66
66
  */
67
67
  zones?: readonly VelocityZoneBandProp[]
68
68
  /**
69
- * Live mode: index of the most-recently-completed rep. That bar animates in
70
- * with a "pop"; if it is also the current set peak (a new best), it "bounces"
71
- * instead. Only the latest bar animates. Honors `prefers-reduced-motion`.
72
- * Framed `expanded` chart only (interactive tap-to-expand use).
69
+ * Bar-fill coloring mode. `loss` (DEFAULT) color each performed bar by its
70
+ * velocity LOSS from the set's OWN best (see {@link getVelocityLossColor}), so a
71
+ * fatiguing set reads green→red by loss regardless of its absolute speed; ignores
72
+ * {@link zones} (it is a wholly separate scale). `zone` color from the absolute
73
+ * velocity-zone scale ({@link zones} when provided, else the built-in
74
+ * ≥1.0/≥0.75/≥0.5 default). `todo`/`variable`/`continue` slots are unaffected either way.
75
+ */
76
+ barColor?: 'zone' | 'loss'
77
+ /**
78
+ * `hero` only: draw the VL20 / VL30 velocity-loss decision bands behind the bars
79
+ * (see {@link VelocityLossBands}). Defaults to on when {@link barColor} is `loss`
80
+ * (the default), off for `zone` — the bands and the loss bar-fill are the two
81
+ * halves of the same loss-relative language.
82
+ */
83
+ showLossBands?: boolean
84
+ /**
85
+ * `hero` only: `up` (default) grows bars UP from a bottom baseline; `down` mirrors
86
+ * the whole plot (bars grow DOWN from a top baseline, text upright). The diverging
87
+ * dual is just an `up` hero over a `down` hero sharing one axis — so any hero
88
+ * improvement reaches the dual for free.
89
+ */
90
+ orientation?: 'up' | 'down'
91
+ /**
92
+ * `hero` only: OVERRIDE the height-scaling denominator with `scaleMax * headroom` instead of
93
+ * this strip's own `peak`/`fixed` scale. Affects BAR HEIGHT ONLY — velocity-LOSS coloring and
94
+ * the running-best reference line still measure off THIS strip's own velocities. The diverging
95
+ * dual passes the pair's shared max here so a stronger arm's bars read TALLER against one common
96
+ * scale while each arm still colors by its own loss.
97
+ */
98
+ scaleMax?: number
99
+ /**
100
+ * `hero` only (internal): suppress this strip's own 2px baseline border. The diverging dual sets
101
+ * it on BOTH composed wings and draws ONE shared centre axis where they meet, so the axis is a
102
+ * single crisp line rather than two abutting baselines reading as a double-thick rule.
103
+ */
104
+ hideBaseline?: boolean
105
+ /**
106
+ * `hero` only (internal): the EXACT rendered column structure, overriding this strip's own
107
+ * slot-building. The diverging dual builds ONE index-locked structure shared by both wings (same
108
+ * rep indices, same WIDE-gap positions, same column count; a column a side didn't log renders
109
+ * `empty`) and passes it here so bars line up column-for-column across the centre axis. Bar
110
+ * height / color / the running-best reference still read this strip's own `velocities`.
111
+ */
112
+ columnSlots?: SetSlot[]
113
+ /**
114
+ * `hero` only: an optional stream / slot NAME (e.g. "Left Arm"), rendered as a rotated vertical
115
+ * edge label down the chart's left gutter — the same treatment the diverging dual gives each wing,
116
+ * so a single hero and a dual read consistently. Omitted → no gutter.
117
+ */
118
+ label?: string
119
+ /**
120
+ * Live mode: index of the most-recently-completed rep. That bar GROWS UP FROM
121
+ * THE BASELINE to its full height as it enters, tracking the rep as it lands;
122
+ * if it is also the current set peak (a new best) the growth slightly
123
+ * overshoots then settles, reading as a small bounce. Only the latest bar
124
+ * animates. Honors `prefers-reduced-motion`. Framed `expanded` chart and
125
+ * `hero` only (interactive tap-to-expand use).
73
126
  */
74
127
  liveRepIndex?: number
75
128
  /**
@@ -81,16 +134,17 @@ export interface VelocityStripProps extends ViewProps {
81
134
  onToggle?: () => void
82
135
  onRepPress?: (index: number, velocity: number) => void
83
136
  /**
84
- * `mini` — a flat 3px static strip (set-type aware). `expanded` the velocity-
85
- * HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
86
- * {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface,
87
- * padding, per-bar m/s labels, mean/loss info row, interactive collapse); with both
88
- * off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}. `hero`
89
- * — the across-the-room, single-set wall treatment: tall bars, a per-bar m/s value
90
- * label, a dashed running-best reference line, and dashed placeholders for the reps
91
- * still to come (see {@link targetReps}). Reuses the zone scale and the live-rep pop.
137
+ * `compact` — the flat resting strip: {@link SetBarChart} in FLAT mode (uniform short bars, no
138
+ * value labels), sharing hero's exact geometry (colors, paper, spacing, chunk-notch, slots, gutter).
139
+ * `expanded` the velocity-HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
140
+ * {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface, padding, per-bar
141
+ * m/s labels, mean/loss info row, interactive collapse); with both off it's a bare strip — the
142
+ * active-set "spotlight" of {@link ExerciseCard}. `hero` — the across-the-room, single-set wall
143
+ * treatment: tall bars, a per-bar m/s value label, a dashed running-best reference line, and dashed
144
+ * placeholders for the reps still to come (see {@link targetReps}). All three share SetBarChart, so
145
+ * bars align across compact / expanded / hero.
92
146
  */
93
- variant?: 'mini' | 'expanded' | 'hero'
147
+ variant?: 'compact' | 'expanded' | 'hero'
94
148
  /** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
95
149
  height?: number
96
150
  /**
@@ -182,6 +236,44 @@ export function calculateMeanVelocity(velocities: number[]): number {
182
236
  return sum / velocities.length
183
237
  }
184
238
 
239
+ /**
240
+ * Velocity-loss band thresholds (loss %), the same VL10/VL20/VL30 coaching cues
241
+ * FatigueMeter's default thresholds use — kept in sync so a `barColor="loss"`
242
+ * strip and a fatigue hero's VL20/VL30 reference bands agree on where amber/red start.
243
+ */
244
+ const VL_LOSS_THRESHOLDS: readonly [number, number, number] = [10, 20, 30]
245
+
246
+ /**
247
+ * Map a per-rep velocity LOSS (%, from the set's own best — see {@link
248
+ * velocityLossForRep}) onto the same green→gold→orange→red scale as the absolute
249
+ * zone scale ({@link VEL_COLORS}), banded at the {@link VL_LOSS_THRESHOLDS}
250
+ * VL10/VL20/VL30 cues. Past VL20 reads orange ("past VL20 = amber" in coaching
251
+ * terms — this scale's amber/gold band is the yellow stop, orange is the VL20+
252
+ * band), past VL30 reads red, so a fatiguing set reads green→red by LOSS
253
+ * regardless of how slow its absolute velocity is.
254
+ */
255
+ export function getVelocityLossColor(lossPct: number): string {
256
+ const [t1, t2, t3] = VL_LOSS_THRESHOLDS
257
+ if (lossPct < t1) return VEL_COLORS.green
258
+ if (lossPct < t2) return VEL_COLORS.yellow
259
+ if (lossPct < t3) return VEL_COLORS.orange
260
+ return VEL_COLORS.red
261
+ }
262
+
263
+ /**
264
+ * A single rep's velocity loss (%) relative to the set's own best rep, clamped to
265
+ * ≥ 0 (a best-so-far rep, or a set with no positive best, reports 0 loss — green).
266
+ * Rounded (matching {@link calculateVelocityLoss}'s convention) so a rep that is
267
+ * arithmetically the set's best — or lands exactly on a VL threshold — doesn't drift
268
+ * across a color-band boundary on floating-point noise (e.g. `1.0 − 0.9` ≈ `0.0999…998`).
269
+ * Feeds `barColor="loss"` bar coloring; distinct from {@link calculateVelocityLoss},
270
+ * which reports only the set's FINAL loss (last rep vs best) as a single summary number.
271
+ */
272
+ function velocityLossForRep(velocity: number, best: number): number {
273
+ if (best <= 0) return 0
274
+ return Math.max(0, Math.round(((best - velocity) / best) * 100))
275
+ }
276
+
185
277
  /** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
186
278
  function classifyBand(
187
279
  velocity: number,
@@ -197,6 +289,18 @@ function bandColor(band: VelocityZoneBandProp): string {
197
289
  return bandIdToEffortColor[band.id] ?? VEL_COLORS.green
198
290
  }
199
291
 
292
+ /**
293
+ * The zone → bar-color resolver, single-sourced so every variant (and the dual
294
+ * diverging sibling) colors reps identically: profile-derived `zones` map through
295
+ * {@link bandColor}; the default (no-zones) path uses the built-in 4-color scale.
296
+ * Color is ALWAYS the velocity zone — never the voltra side.
297
+ */
298
+ function makeBarColorFor(zones?: readonly VelocityZoneBandProp[]): (v: number) => string {
299
+ const hasZones = zones != null && zones.length > 0
300
+ return (v: number): string =>
301
+ hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
302
+ }
303
+
200
304
  function getLossStyle(loss: number): Record<string, string> | null {
201
305
  if (loss > 25) return { color: VEL_COLORS.red }
202
306
  if (loss > 20) return { color: VEL_COLORS.orange }
@@ -248,11 +352,6 @@ const REP_GAP = 2
248
352
  */
249
353
  const WIDE_GAP = 8
250
354
 
251
- /** Grey fill for planned-but-unperformed reps (charcoal placeholder, literal hex). */
252
- const TODO_COLOR = primitiveColors.charcoal[300]
253
- /** The thin cyan-800 outline on the open-ended "continue" slot — reads as "keep going". */
254
- const CONTINUE_OUTLINE = primitiveRamps.cyan[800]
255
-
256
355
  /** The done-velocity array a set contributes to the mean / loss / zone summary. */
257
356
  function deriveDoneVelocities(set: VelocitySet): number[] {
258
357
  switch (set.type) {
@@ -352,353 +451,696 @@ function setAccessibilityLabel(set: VelocitySet, repCount: number): string {
352
451
  }
353
452
  }
354
453
 
355
- /** Per-slot accessibility label for the expanded set-type bars. */
356
- function slotAccessibilityLabel(slot: VelocitySlot, index: number): string {
357
- switch (slot.kind) {
358
- case 'rep':
359
- return `Rep ${index + 1}: ${formatVelocity(slot.velocity ?? 0)} meters per second`
360
- case 'todo':
361
- return `Rep ${index + 1}: planned`
362
- case 'variable':
363
- return `Rep ${index + 1}: variable`
364
- case 'continue':
365
- return 'Keep going'
366
- }
454
+ /** Default framed `expanded` chart height (px). */
455
+ const EXPANDED_HEIGHT = 60
456
+ /** Default `compact` (flat resting strip) height (px) — a THIN radius-2 pill row, the resting glance. */
457
+ const COMPACT_HEIGHT = 8
458
+
459
+ /** The dashed running-best reference line color (the lightest charcoal step). */
460
+ const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
461
+
462
+ /**
463
+ * A dashed running-best reference line spanning the plot width, absolutely positioned a
464
+ * pixel `offset` from the given `anchor` edge. Shared by the single `hero` chart (anchored
465
+ * to the baseline, `bottom`) and BOTH wings of the diverging dual chart (anchored to the
466
+ * centre axis, `top`) so the three sites render one line treatment (charcoal-0, 1px dashed).
467
+ * Presentation-only + non-interactive — the numeric best lives in each chart's container
468
+ * accessibility label, so the line itself is hidden from the a11y tree.
469
+ *
470
+ * REUSE FOLLOW-UP: `Sparkline` hand-rolls the same dashed-line overlay (plus opacity, an
471
+ * optional label, and a data-domain Y), and two lab specimens duplicate it again. A
472
+ * top-level `ReferenceLine` overlay primitive unifying all of them is a scoped follow-up
473
+ * (see the Workout README) — kept in-file here to keep the hero PR focused.
474
+ */
475
+ function DashedReferenceLine({
476
+ anchor,
477
+ offset,
478
+ testID,
479
+ }: {
480
+ anchor: 'top' | 'bottom'
481
+ offset: number
482
+ testID: string
483
+ }) {
484
+ const edge: ViewStyle = anchor === 'top' ? { top: offset } : { bottom: offset }
485
+ return (
486
+ <View
487
+ accessibilityElementsHidden
488
+ style={{
489
+ position: 'absolute',
490
+ left: 0,
491
+ right: 0,
492
+ borderTopWidth: 1,
493
+ borderStyle: 'dashed',
494
+ borderColor: HERO_REFERENCE_COLOR,
495
+ pointerEvents: 'none',
496
+ ...edge,
497
+ }}
498
+ testID={testID}
499
+ />
500
+ )
367
501
  }
368
502
 
369
- /** Straight-set expanded stub height for a planned (todo) rep, as a % of the plot. */
370
- const EXPANDED_TODO_STUB_PCT = 16
371
- /** Expanded height for advanced set-types (drop / myo / cluster / range / amrap): a short mini-style bar. */
372
- const EXPANDED_ENCODED_PCT = 45
503
+ const BAND_LABEL_FONT = 'monospace'
504
+ /**
505
+ * Below this plot height (px) the VL20/VL30 labels are DROPPED entirely (the dashed lines + washes
506
+ * stay) rather than shrunk into an overlapping smear. Tuned so a 120px dual (≈41px plot/wing) and the
507
+ * cramped mid-small range drop, while the accepted board scale (≈76px plot/wing) keeps its labels.
508
+ */
509
+ const VL_LABEL_MIN_PLOT = 65
510
+ const VL_SEMANTIC = getSemanticColors('dark')
373
511
 
374
- /** Default framed `expanded` chart height (px). */
375
- const EXPANDED_HEIGHT = 60
376
512
  /**
377
- * The `scale="fixed"` velocity ceiling (m/s). `peak` scales to the set's own max
378
- * (+15% headroom); `fixed` uses this constant so a bar's height reads the same
379
- * absolute velocity across sets (the spotlight).
513
+ * Velocity-LOSS decision bands for the `hero` variant: the VL20 / VL30 coaching
514
+ * cues (off the running best) drawn as absolutely-positioned bands + dashed
515
+ * threshold lines BEHIND the bars, on the hero's own peak scale so a bar dropping
516
+ * into the amber → red band reads as "these are your last effective reps". Shared
517
+ * by the single hero, the diverging dual wings, and {@link VelocityHero}; render
518
+ * it as the first child of the bottom-anchored plot container.
519
+ *
520
+ * `best` is the running-best velocity (the peak the loss is measured from);
521
+ * `scaleDenom` / `plotHeight` are the hero chart's bar-scaling geometry — a
522
+ * velocity `v` sits `(v / scaleDenom) * plotHeight` px up from the baseline,
523
+ * matching {@link HeroVelocityChart}'s `barHeight`.
380
524
  */
381
- const FIXED_MAX_VELOCITY = 1.15
382
- /** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
383
- const BARE_STUB_HEIGHT = 3
384
-
385
- // --- Hero variant ------------------------------------------------------------
386
- // The across-the-room, single-set wall treatment. Absorbs the R2 "HeroVelocityBars"
387
- // candidate into VelocityStrip: tall bars + per-bar value labels + a dashed
388
- // running-best reference line + dashed placeholders for the reps still to come.
389
-
390
- /** Default `hero` plot height (px) tall enough to read a set's velocity shape across a room. */
391
- const HERO_HEIGHT = 220
392
- /** Vertical band reserved above the tallest bar for its value label (px). */
393
- const HERO_LABEL_HEADROOM = 20
394
- /** Gap between hero bars (px) a group-notch-free even rhythm at wall scale. */
395
- const HERO_BAR_GAP = 8
396
- /** Cap on a single hero bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
397
- const HERO_BAR_MAX_WIDTH = 120
398
- /** Below this per-bar width the value labels collide, so all but the peak + live rep are dropped. */
399
- const HERO_LABEL_MIN_BAR_WIDTH = 30
400
- /** Top-corner radius on hero bars (px). */
401
- const HERO_BAR_RADIUS = 5
402
- /** Minimum drawn height of a performed hero bar (px) a near-zero rep still reads as a rep. */
403
- const HERO_MIN_BAR_HEIGHT = 4
404
- /** Dashed-stub border for a planned-but-unperformed hero rep (charcoal, reads on a dark wall). */
405
- const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
406
- /** The dashed running-best reference line + its label (the lightest charcoal step). */
407
- const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
525
+ export function VelocityLossBands({
526
+ best,
527
+ scaleDenom,
528
+ plotHeight,
529
+ flip = false,
530
+ }: {
531
+ best: number
532
+ scaleDenom: number
533
+ plotHeight: number
534
+ /** The parent plot is vertically mirrored (a `down` wing) counter-flip the labels upright. */
535
+ flip?: boolean
536
+ }) {
537
+ if (best <= 0 || scaleDenom <= 0 || plotHeight <= 0) return null
538
+ const yOf = (v: number): number => (v / scaleDenom) * plotHeight
539
+ const vl20 = best * 0.8
540
+ const vl30 = best * 0.7
541
+ // The VL20 / VL30 lines sit ~0.09·plotHeight apart, so on a short chart their labels crowd. Scale
542
+ // the label font to the plot height; below VL_LABEL_MIN_PLOT DROP the labels entirely (keep the
543
+ // dashed lines + washes) rather than shrink them into an illegible smear.
544
+ const vlFont = Math.round(Math.max(7, Math.min(9, plotHeight * 0.05)))
545
+ const showLabels = plotHeight >= VL_LABEL_MIN_PLOT
546
+ const band = (loV: number, hiV: number, color: string) => (
547
+ <View
548
+ style={{
549
+ position: 'absolute',
550
+ left: 0,
551
+ right: 0,
552
+ bottom: yOf(loV),
553
+ height: Math.max(0, yOf(hiV) - yOf(loV)),
554
+ backgroundColor: color,
555
+ }}
556
+ />
557
+ )
558
+ // The label sits ~90% along the threshold, with the dashed line breaking around it (a long segment
559
+ // before + a short stub after) — near the quiet right end where a declining set has room. Below the
560
+ // label threshold the line spans full width with no text.
561
+ const threshold = (v: number, color: string, label: string) => (
562
+ <View
563
+ style={{
564
+ position: 'absolute',
565
+ left: 0,
566
+ right: 0,
567
+ bottom: yOf(v),
568
+ // Collapse to zero height so the dashed border lands EXACTLY on `yOf(v)` — the
569
+ // band edge — instead of floating up half a text-row (the label then centres on
570
+ // the line, breaking it). Matches DashedReferenceLine's bare-border anchoring.
571
+ height: 0,
572
+ flexDirection: 'row',
573
+ alignItems: 'center',
574
+ }}
575
+ >
576
+ <View style={{ flex: 9, borderTopWidth: 1, borderStyle: 'dashed', borderColor: color }} />
577
+ {showLabels ? (
578
+ <>
579
+ <Text
580
+ style={{
581
+ marginHorizontal: 6,
582
+ fontSize: vlFont,
583
+ fontWeight: '800',
584
+ fontFamily: BAND_LABEL_FONT,
585
+ color,
586
+ ...(flip ? { transform: [{ scaleY: -1 as number }] } : null),
587
+ }}
588
+ >
589
+ {label}
590
+ </Text>
591
+ <View style={{ flex: 1, borderTopWidth: 1, borderStyle: 'dashed', borderColor: color }} />
592
+ </>
593
+ ) : null}
594
+ </View>
595
+ )
596
+ return (
597
+ <View
598
+ accessibilityElementsHidden
599
+ pointerEvents="none"
600
+ style={{ position: 'absolute', left: 0, right: 0, bottom: 0, top: 0 }}
601
+ testID="velocity-loss-bands"
602
+ >
603
+ {band(0, vl30, alpha(VL_SEMANTIC['status-error'], 0.09))}
604
+ {band(vl30, vl20, alpha(VL_SEMANTIC['status-warning'], 0.08))}
605
+ {threshold(vl20, alpha(VL_SEMANTIC['status-warning'], 0.75), 'VL 20%')}
606
+ {threshold(vl30, alpha(VL_SEMANTIC['status-error'], 0.75), 'VL 30%')}
607
+ </View>
608
+ )
609
+ }
408
610
 
409
611
  /**
410
- * Headroom above the peak bar: just enough to seat its value label without a big empty
411
- * band at the top (was 1.15 too airy for the wall hero).
612
+ * The velocity hero's reference overlay, painted by {@link SetBarChart} via `renderReference` from
613
+ * the chart geometry: the VL20 / VL30 loss decision bands (when `showLossBands`) behind the bars,
614
+ * plus the dashed running-best line at the peak. Both measure off the chart's OWN `best` — the
615
+ * per-side loss language that survives the dual's shared height scale. Unlabeled by design (the peak
616
+ * bar already shows its value; the numeric best is in the chart's accessibility label).
412
617
  */
413
- const PEAK_HEADROOM = 1.06
618
+ function velocityReferenceOverlay(g: SetBarGeometry, showLossBands: boolean) {
619
+ const referencePx = g.best > 0 && g.scaleDenom > 0 ? Math.min(g.plotHeight, g.yOf(g.best)) : 0
620
+ return (
621
+ <>
622
+ {showLossBands && (
623
+ <VelocityLossBands
624
+ best={g.best}
625
+ scaleDenom={g.scaleDenom}
626
+ plotHeight={g.plotHeight}
627
+ flip={g.flip}
628
+ />
629
+ )}
630
+ {referencePx > 0 && (
631
+ <DashedReferenceLine
632
+ anchor="bottom"
633
+ offset={referencePx}
634
+ testID="velocity-hero-reference"
635
+ />
636
+ )}
637
+ </>
638
+ )
639
+ }
640
+
641
+ /** Framed expanded collapse-animation duration (ms). */
642
+ const ANIMATION_DURATION = 400
414
643
 
415
- /** The bar-height scaling denominator for the given `scale` (guarded 0 by callers). */
416
- function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
417
- return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * PEAK_HEADROOM
644
+ // --- Dual (bilateral) diverging chart ----------------------------------------
645
+ // The two-device (LEFT + RIGHT voltra) treatment. Instead of two stacked single
646
+ // heroes with independent baselines, ONE diverging chart shares a horizontal centre
647
+ // axis: LEFT reps grow UP, RIGHT reps grow DOWN, one mirrored pair per rep index. The
648
+ // asymmetry (left-dominant / right-lagging) reads pre-attentively as the silhouette.
649
+ // It reuses VelocityStrip's slot model ({@link buildSlots}/{@link VelocitySlot}), its
650
+ // zone color scale ({@link makeBarColorFor}), the hero geometry constants, and the
651
+ // live-rep entrance ({@link useLiveRepGrowth}) — side is POSITION only, never hue.
652
+ //
653
+ // LIVE-REP ENTRANCE — do NOT re-add a dual-only one. The dual used to hand-roll its wing bars and
654
+ // drive them from its own scale-pop hook, so the newest-rep animation existed TWICE and the two
655
+ // copies drifted. Composing single strips retired that hook: `liveRepIndex` is now an ordinary
656
+ // prop on each wing, so both wings animate through the same code path as a single hero and an
657
+ // entrance that fires on one wing only is not expressible. Whether the wings should POP (a
658
+ // whole-bar scale) rather than GROW from the axis is a live design question — but it belongs in
659
+ // SetBarChart's shared entrance, applied to the single hero and the wings together. Reintroducing
660
+ // it here would rebuild exactly the divergence this composition removed.
661
+
662
+ /** One voltra's velocity stream — the SAME shape VelocityStrip accepts (one of these). */
663
+ export interface DualVelocityStream {
664
+ /** Per-rep MEAN concentric velocity (m/s) for this side. */
665
+ velocities?: number[]
666
+ /** A structured set descriptor for this side (drives the typed slot vocabulary). */
667
+ set?: VelocitySet
668
+ /**
669
+ * The side's SLOT NAME (e.g. "Left Arm"), rendered as the vertical edge label. This is
670
+ * the slot's identity supplied by data — there is no hardcoded LEFT/RIGHT fallback. When
671
+ * absent or empty, that side renders no label.
672
+ */
673
+ label?: string
418
674
  }
419
675
 
420
- /** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
421
- function bareSlotHeight(slot: VelocitySlot, height: number, denom: number): number {
422
- if (slot.kind !== 'rep' || denom <= 0) return BARE_STUB_HEIGHT
423
- const ratio = Math.min(1, (slot.velocity ?? 0) / denom)
424
- return Math.max(BARE_STUB_HEIGHT, ratio * height)
676
+ export interface DualVelocityStripProps extends ViewProps {
677
+ /** The up-wing stream drawn growing UP from the centre axis; its edge name comes from `left.label`. */
678
+ left: DualVelocityStream
679
+ /** The down-wing stream drawn growing DOWN from the centre axis; its edge name comes from `right.label`. */
680
+ right: DualVelocityStream
681
+ /**
682
+ * Optional velocity-zone bands (shape-compatible with WA's `VelocityZones.bands`),
683
+ * shared with {@link VelocityStrip}. Colors the reps by zone on BOTH sides; side is
684
+ * never encoded by hue. When omitted the built-in default scale is used.
685
+ */
686
+ zones?: readonly VelocityZoneBandProp[]
687
+ /**
688
+ * Bar-fill coloring mode, matching {@link VelocityStrip}. `loss` (DEFAULT) — each
689
+ * wing colors by its velocity LOSS from THAT arm's own best (per-wing, since arms
690
+ * fatigue independently). `zone` — the shared absolute zone scale. Side is never
691
+ * encoded by hue either way.
692
+ */
693
+ barColor?: 'zone' | 'loss'
694
+ /**
695
+ * Planned rep count. Reps beyond a side's performed count draw as mirrored dashed
696
+ * todo stubs (same "3 of 8 done" read as the single hero), on both wings.
697
+ */
698
+ targetReps?: number
699
+ /** Index of the most-recently-completed rep; that mirrored pair animates in (hero only). */
700
+ liveRepIndex?: number
701
+ /**
702
+ * `hero` — the across-the-room wall scale: tall wings, per-rep m/s value labels, and a dashed
703
+ * running-best reference line per side. `compact` — the flat resting form: dual-hero but flat +
704
+ * no labels (both wings are the `compact` VelocityStrip variant), same aligned structure + shared
705
+ * gutter + axis. `rail` — the compact rail-expanded lean renderer, no labels / reference lines.
706
+ */
707
+ variant?: 'hero' | 'compact' | 'rail'
708
+ /**
709
+ * Bar-height scaling, shared across BOTH wings so the L/R asymmetry reads as bar
710
+ * length against one scale. `peak` (default) = the pair's max +headroom; `fixed` =
711
+ * a fixed velocity ceiling.
712
+ */
713
+ scale?: 'peak' | 'fixed'
714
+ /** Total plot height (px), split evenly into the up (L) and down (R) wings. */
715
+ height?: number
716
+ className?: string
425
717
  }
426
718
 
427
- function getReducedMotionPreference(): boolean {
428
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
429
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches
719
+ /** Default `hero` diverging height (px) matches the single hero. */
720
+ const DUAL_HERO_HEIGHT = SET_BAR_DEFAULT_HEIGHT
721
+ /** Default `rail` diverging height (px) — compact enough to sit inside a rail slot. */
722
+ const DUAL_RAIL_HEIGHT = 96
723
+ /** Default `compact` diverging height (px) — two 5px rounded L/R sub-segments + the 1.5px fold gap. */
724
+ const DUAL_COMPACT_HEIGHT = 11.5
725
+
726
+ // --- Rail geometry (the compact dedicated path) ------------------------------
727
+ // The `hero` variant COMPOSES two single VelocityStrip heroes, so it inherits the
728
+ // hero geometry for free. The `rail` variant is far smaller than a hero (no value
729
+ // labels, reference lines, paper, or label headroom), so composing the hero would
730
+ // drag all of that in; it keeps a lean dedicated renderer at these compact metrics.
731
+ /** The vertical gap between the up/down wings on the axis-less rail (value-height dual-expanded). */
732
+ const DUAL_WING_GAP = 2
733
+ /** The centre gap (px) splitting the folded compact dual's L (top) and R (bottom) halves — a gap, no line. */
734
+ const COMPACT_FOLD_GAP = 1.5
735
+ /** Each folded-compact sub-segment's height (px) — a rounded pill per side, above/below the fold gap. */
736
+ const COMPACT_FOLD_HALF = 5
737
+
738
+ /** One side's performed per-rep velocities — flattened from a `set` descriptor, or the raw array. */
739
+ function streamDone(stream: DualVelocityStream): number[] {
740
+ return stream.set ? deriveDoneVelocities(stream.set) : (stream.velocities ?? [])
430
741
  }
431
742
 
432
- /** Track the OS "reduce motion" preference; falls back to `false` (jsdom/SSR). */
433
- function usePrefersReducedMotion(): boolean {
434
- const [reduced, setReduced] = useState(getReducedMotionPreference)
435
- useEffect(() => {
436
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
437
- const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
438
- const handler = () => setReduced(mq.matches)
439
- handler()
440
- mq.addEventListener?.('change', handler)
441
- return () => mq.removeEventListener?.('change', handler)
442
- }, [])
443
- return reduced
743
+ /** A side's NATURAL slot list its `set` slot vocabulary, or bare rep slots + a `targetReps` remainder. */
744
+ function streamNaturalSlots(stream: DualVelocityStream, targetReps?: number): SetSlot[] {
745
+ if (stream.set) {
746
+ return buildSlots(stream.set).map((s) => ({
747
+ kind: s.kind,
748
+ value: s.velocity,
749
+ leadingGap: s.leadingGap,
750
+ }))
751
+ }
752
+ const reps: SetSlot[] = (stream.velocities ?? []).map((v, i) => ({
753
+ kind: 'rep',
754
+ value: v,
755
+ leadingGap: i === 0 ? 0 : REP_GAP,
756
+ }))
757
+ const pad = Math.max(0, (targetReps ?? 0) - reps.length)
758
+ return [...reps, ...Array.from({ length: pad }, () => ({ kind: 'todo' as const }))]
444
759
  }
445
760
 
446
- const ANIMATION_DURATION = 400
447
- const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
448
- const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
761
+ /**
762
+ * The window-kind precedence when merging two sides' cells at a column: a set-type window (variable /
763
+ * continue) that either side has wins; else if either logged (or plans) a rep there it's a REP column;
764
+ * else it's a shared to-do. So a lagging side's not-yet-logged rep column stays a REP column (rendered
765
+ * `empty` for that side), index-locked to the other side's rep — never shifted or re-spaced.
766
+ */
767
+ function mergeColumnKind(l: SetSlot | undefined, r: SetSlot | undefined): SetSlot['kind'] {
768
+ const kinds = [l?.kind, r?.kind]
769
+ if (kinds.includes('variable')) return 'variable'
770
+ if (kinds.includes('continue')) return 'continue'
771
+ if (kinds.includes('rep')) return 'rep'
772
+ return 'todo'
773
+ }
774
+
775
+ /** One side's cell at a merged column: its logged rep (with value), else `empty` for a rep column, else the shared window kind. */
776
+ function sideColumnCell(
777
+ slot: SetSlot | undefined,
778
+ kind: SetSlot['kind'],
779
+ leadingGap: number
780
+ ): SetSlot {
781
+ if (kind !== 'rep') return { kind, leadingGap }
782
+ return slot?.kind === 'rep'
783
+ ? { kind: 'rep', value: slot.value, leadingGap }
784
+ : { kind: 'empty', leadingGap }
785
+ }
449
786
 
450
787
  /**
451
- * The newest-rep entrance shared by the framed `expanded` chart and `hero`: a
452
- * bounce when the rep is a new set peak, a pop otherwise. Honors reduced motion.
453
- * Returns the scale `Animated.Value` for the live bar; inert while `active` is
454
- * false or no live rep is present.
788
+ * Build ONE index-locked column structure shared by both diverging wings from their natural slot
789
+ * lists: same column count, same rep indices, same WIDE-gap positions. Each column resolves to a
790
+ * shared kind ({@link mergeColumnKind}); per side it's that side's rep value, or `empty` when the
791
+ * side hasn't logged that rep (assumes symmetric bilateral reps — both sides step together).
455
792
  */
456
- function useLiveRepPop(
457
- active: boolean,
458
- liveRepIndex: number | undefined,
459
- liveVelocity: number | undefined,
460
- isNewPeak: boolean
461
- ): Animated.Value {
462
- const prefersReducedMotion = usePrefersReducedMotion()
463
- const [liveScale] = useState(() => new Animated.Value(1))
464
- useEffect(() => {
465
- if (!active || liveRepIndex == null || liveVelocity == null) return
466
- if (prefersReducedMotion) {
467
- liveScale.setValue(1)
468
- return
469
- }
470
- if (isNewPeak) {
471
- liveScale.setValue(1)
472
- Animated.sequence([
473
- Animated.timing(liveScale, {
474
- toValue: 1.25,
475
- duration: 150,
476
- easing: Easing.out(Easing.quad),
477
- useNativeDriver: true,
478
- }),
479
- Animated.spring(liveScale, {
480
- toValue: 1,
481
- friction: 3,
482
- tension: 140,
483
- useNativeDriver: true,
484
- }),
485
- ]).start()
486
- } else {
487
- liveScale.setValue(0.8)
488
- Animated.timing(liveScale, {
489
- toValue: 1,
490
- duration: 300,
491
- easing: POP_EASING,
492
- useNativeDriver: true,
493
- }).start()
494
- }
495
- }, [active, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
496
- return liveScale
793
+ function alignDualSlots(left: SetSlot[], right: SetSlot[]): { left: SetSlot[]; right: SetSlot[] } {
794
+ const n = Math.max(left.length, right.length)
795
+ const L: SetSlot[] = []
796
+ const R: SetSlot[] = []
797
+ for (let i = 0; i < n; i++) {
798
+ const lt = left[i]
799
+ const rt = right[i]
800
+ const kind = mergeColumnKind(lt, rt)
801
+ const gap = Math.max(lt?.leadingGap ?? REP_GAP, rt?.leadingGap ?? REP_GAP)
802
+ L.push(sideColumnCell(lt, kind, gap))
803
+ R.push(sideColumnCell(rt, kind, gap))
804
+ }
805
+ return { left: L, right: R }
497
806
  }
498
807
 
499
- interface HeroVelocityChartProps {
500
- /** Performed per-rep mean concentric velocities (m/s). */
501
- doneVelocities: number[]
502
- /** Zone → color resolver, shared with the other variants (single-sources the scale). */
503
- barColorFor: (velocity: number) => string
504
- /** Bar-height scaling denominator (`peak` set-max or `fixed` ceiling). */
505
- scaleDenom: number
506
- /** Running-best velocity (the set peak) drives the dashed reference line. */
507
- referenceVelocity: number
508
- liveRepIndex?: number
509
- isNewPeak: boolean
808
+ /** Shared inputs for both dual renderers — the resolved per-side done arrays + presentation props. */
809
+ interface DualChartProps {
810
+ leftDone: number[]
811
+ rightDone: number[]
812
+ /**
813
+ * The raw per-side streams. The `hero` renderer passes each side's `set` (or `velocities`)
814
+ * straight through to its composed VelocityStrip hero so a side's set-type WINDOWS (the range
815
+ * cyan variable window, the AMRAP/myo "continue", drop/myo chunk-notch gaps) render on the dual
816
+ * — not just the flattened reps. The lean `rail` renderer ignores them (it only takes velocities).
817
+ */
818
+ leftStream?: DualVelocityStream
819
+ rightStream?: DualVelocityStream
820
+ leftLabel?: string
821
+ rightLabel?: string
822
+ zones?: readonly VelocityZoneBandProp[]
823
+ barColor: 'zone' | 'loss'
824
+ /** Shared across BOTH wings: `peak` (the pair's max +headroom) or `fixed` (a cross-set ceiling). */
825
+ scale: 'peak' | 'fixed'
510
826
  targetReps?: number
511
827
  height: number
512
828
  className?: string
829
+ label: string
513
830
  viewProps: ViewProps
514
831
  }
515
832
 
516
833
  /**
517
- * The `hero` render: the across-the-room, single-set wall treatment. Tall bars +
518
- * a per-bar value label + a dashed running-best reference line + dashed placeholders
519
- * for the reps still to come. Reuses {@link barColorFor} (zone scale) and the
520
- * {@link useLiveRepPop} entrance so it stays consistent with the framed chart.
834
+ * The `hero` diverging chart COMPOSED from two single {@link VelocityStrip} heroes: an `up` hero
835
+ * over a `down` (vertically-mirrored) hero, sharing ONE height scale via {@link
836
+ * VelocityStripProps.scaleMax} and meeting at one shared centre axis. Each wing hides its own
837
+ * baseline ({@link VelocityStripProps.hideBaseline}) so the axis is a single crisp line. Because it
838
+ * is literally two heroes, every hero improvement — paper, loss bands, grow-from-bottom, the
839
+ * running-best reference, surface-relative placeholders, per-side loss coloring — reaches the dual
840
+ * for free. Side is POSITION only: both wings color reps by the SAME `barColor` scale; the shared
841
+ * `scaleMax` makes a stronger arm read TALLER while each wing still colors off its own best.
521
842
  */
522
- function HeroVelocityChart({
523
- doneVelocities,
524
- barColorFor,
525
- scaleDenom,
526
- referenceVelocity,
843
+ function DualVelocityHero({
844
+ leftDone,
845
+ rightDone,
846
+ leftStream,
847
+ rightStream,
848
+ leftLabel,
849
+ rightLabel,
850
+ zones,
851
+ barColor,
852
+ scale,
853
+ targetReps,
527
854
  liveRepIndex,
528
- isNewPeak,
855
+ height,
856
+ className,
857
+ label,
858
+ viewProps,
859
+ }: DualChartProps & { liveRepIndex?: number }) {
860
+ // The two wings are separated by the SAME small gap as the expanded/rail dual (no centre-axis rule).
861
+ const plotHalf = (height - DUAL_WING_GAP) / 2
862
+ // ONE shared height scale across both wings so the L/R asymmetry reads as bar length, not two
863
+ // scales; each composed strip still colors by its OWN loss and draws its OWN best reference.
864
+ // `peak` shares the pair max via `scaleMax`; `fixed` shares the cross-set ceiling (both wings
865
+ // resolve the same fixed ceiling), so `scaleMax` is left off and `scale` carries it.
866
+ const sharedMax = Math.max(...leftDone, ...rightDone, 0)
867
+ // ONE index-locked column structure shared by both wings: same rep indices, same WIDE-gap
868
+ // positions, same column count. A column a side didn't log renders `empty` there — so a lagging
869
+ // side's next rep lands at the SAME column index as the other side, never shifted or re-spaced.
870
+ const aligned = alignDualSlots(
871
+ leftStream ? streamNaturalSlots(leftStream, targetReps) : [],
872
+ rightStream ? streamNaturalSlots(rightStream, targetReps) : []
873
+ )
874
+ const { style: externalStyle, ...restProps } = viewProps
875
+
876
+ // Each wing renders the SHARED aligned structure (`columnSlots`); its own `velocities` still drive
877
+ // bar height / per-side loss color / the running-best reference.
878
+ const wing = (orientation: 'up' | 'down', columns: SetSlot[], done: number[]) => (
879
+ <VelocityStrip
880
+ variant="hero"
881
+ orientation={orientation}
882
+ velocities={done}
883
+ columnSlots={columns}
884
+ scale={scale}
885
+ scaleMax={scale === 'fixed' ? undefined : sharedMax}
886
+ barColor={barColor}
887
+ zones={zones}
888
+ liveRepIndex={liveRepIndex}
889
+ height={plotHalf}
890
+ hideBaseline
891
+ />
892
+ )
893
+
894
+ return (
895
+ <View
896
+ className={className}
897
+ style={[{ height, flexDirection: 'row' }, externalStyle]}
898
+ accessibilityRole="image"
899
+ accessibilityLabel={label}
900
+ testID="dual-velocity-strip"
901
+ {...restProps}
902
+ >
903
+ {/* The SHARED gutter/side-rail (34px, no hairline) — one section per wing. A single hero renders
904
+ the same rail with ONE section, so it's pixel-identical to this upper half. */}
905
+ <ChartSideRail
906
+ sectionExtent={plotHalf}
907
+ variant="hero"
908
+ sections={[
909
+ { label: leftLabel, testID: 'dual-velocity-side-label-L' },
910
+ { label: rightLabel, testID: 'dual-velocity-side-label-R' },
911
+ ]}
912
+ />
913
+
914
+ {/* The two composed heroes, separated by the shared wing gap (no centre-axis rule). Their own
915
+ labels are redundant with the dual's summary label, so the wings are a11y-hidden. */}
916
+ <View style={{ flex: 1, gap: DUAL_WING_GAP }}>
917
+ <View accessibilityElementsHidden testID="dual-velocity-wing-up">
918
+ {wing('up', aligned.left, leftDone)}
919
+ </View>
920
+ <View accessibilityElementsHidden testID="dual-velocity-wing-down">
921
+ {wing('down', aligned.right, rightDone)}
922
+ </View>
923
+ </View>
924
+ </View>
925
+ )
926
+ }
927
+
928
+ /**
929
+ * The `rail` diverging chart — a lean, compact dedicated renderer. Composing the hero here would
930
+ * drag in its value labels, reference lines, paper, and label headroom, none of which belong at
931
+ * rail scale; instead it draws mirrored per-side bars at the compact rail metrics. Same shared
932
+ * height scale + per-side loss coloring + mirrored radius + shared axis as the hero, but no labels
933
+ * / reference lines / paper. (Set-type slot windows are a hero-composition concern; rail only ever
934
+ * takes plain velocities.)
935
+ */
936
+ function DualVelocityRail({
937
+ leftDone,
938
+ rightDone,
939
+ leftStream,
940
+ rightStream,
941
+ zones,
942
+ barColor,
943
+ scale,
529
944
  targetReps,
945
+ liveRepIndex,
530
946
  height,
531
947
  className,
948
+ label,
532
949
  viewProps,
533
- }: HeroVelocityChartProps) {
534
- const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
535
- const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
536
-
537
- // Measure the plot so per-bar value labels can thin on a narrow chart (they collide once
538
- // bars get thin). Width 0 (unmeasured) show all, so test/server renders are unchanged.
539
- const [plotW, setPlotW] = useState(0)
540
- const onPlotLayout = (e: LayoutChangeEvent) => setPlotW(e.nativeEvent.layout.width)
541
-
542
- // Reserve a band above the tallest bar for its value label so a peak bar never clips.
543
- const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
544
- const barHeight = (velocity: number): number =>
545
- scaleDenom > 0
546
- ? Math.max(HERO_MIN_BAR_HEIGHT, Math.min(1, velocity / scaleDenom) * plotHeight)
547
- : HERO_MIN_BAR_HEIGHT
548
-
549
- const pendingCount = Math.max(0, (targetReps ?? 0) - doneVelocities.length)
550
- const referencePx =
551
- referenceVelocity > 0 && scaleDenom > 0
552
- ? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
553
- : 0
554
-
555
- // When bars get too thin, keep only the peak + live-rep labels so the rest don't overlap.
556
- const totalSlots = doneVelocities.length + pendingCount
557
- const barWidth =
558
- plotW > 0 && totalSlots > 0
559
- ? Math.min(HERO_BAR_MAX_WIDTH, (plotW - HERO_BAR_GAP * (totalSlots - 1)) / totalSlots)
560
- : HERO_BAR_MAX_WIDTH
561
- const labelsCrowded = plotW > 0 && barWidth < HERO_LABEL_MIN_BAR_WIDTH
562
- // Step the inter-bar gap down with the bars so the gaps never dwarf the bars themselves.
563
- const heroGap =
564
- plotW === 0 ? HERO_BAR_GAP : barWidth < 16 ? 2 : barWidth < 28 ? 4 : HERO_BAR_GAP
565
- const peakIndex = doneVelocities.reduce(
566
- (best, v, i) => (v > doneVelocities[best] ? i : best),
567
- 0,
950
+ }: DualChartProps & { liveRepIndex?: number }) {
951
+ // The lean dual-EXPANDED. Composed from two bare `expanded` strips exactly as the hero composes
952
+ // two heroes, rather than drawing its own bars: composing is what makes the dual inherit the
953
+ // single's bar widths, gaps, chunk-notch, set-type slot windows, paper and live-rep growth. The
954
+ // bespoke renderer this replaces took only velocities, so the dual silently dropped every
955
+ // set-type window and let a lagging side render fewer bars than its partner.
956
+ const plotHalf = (height - DUAL_WING_GAP) / 2
957
+ // ONE shared height scale across both wings, so an L/R asymmetry reads as bar length rather than
958
+ // as two independent scales.
959
+ const sharedMax = Math.max(...leftDone, ...rightDone, 0)
960
+ // ONE index-locked column structure. A column a side did not log renders as an aligned empty, so
961
+ // the lagging side's next rep lands at the SAME column index as its partner's.
962
+ const aligned = alignDualSlots(
963
+ leftStream ? streamNaturalSlots(leftStream, targetReps) : [],
964
+ rightStream ? streamNaturalSlots(rightStream, targetReps) : []
568
965
  )
569
- const showBarLabel = (i: number): boolean =>
570
- !labelsCrowded || i === peakIndex || i === liveRepIndex
966
+ const { style: externalStyle, ...restProps } = viewProps
571
967
 
572
- const repCount = doneVelocities.length
573
- const total = Math.max(repCount, targetReps ?? repCount)
574
- const label =
575
- referenceVelocity > 0
576
- ? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(referenceVelocity)} meters per second`
577
- : `Velocity chart, ${repCount} of ${total} reps`
968
+ const wing = (orientation: 'up' | 'down', columns: SetSlot[], done: number[]) => (
969
+ <VelocityStrip
970
+ variant="expanded"
971
+ showNumbers={false}
972
+ showInfo={false}
973
+ orientation={orientation}
974
+ velocities={done}
975
+ columnSlots={columns}
976
+ scale={scale}
977
+ scaleMax={scale === 'fixed' ? undefined : sharedMax}
978
+ barColor={barColor}
979
+ zones={zones}
980
+ liveRepIndex={liveRepIndex}
981
+ height={plotHalf}
982
+ />
983
+ )
984
+
985
+ return (
986
+ <View
987
+ className={className}
988
+ style={[{ height, flexDirection: 'column', gap: DUAL_WING_GAP }, externalStyle]}
989
+ accessibilityRole="image"
990
+ accessibilityLabel={label}
991
+ testID="dual-velocity-strip"
992
+ {...restProps}
993
+ >
994
+ {/* No gutter, side labels or centre axis at rail scale — the wings read as two rows via the
995
+ shared gap alone. Their own labels would duplicate the dual's summary label. */}
996
+ <View accessibilityElementsHidden testID="dual-velocity-wing-up">
997
+ {wing('up', aligned.left, leftDone)}
998
+ </View>
999
+ <View accessibilityElementsHidden testID="dual-velocity-wing-down">
1000
+ {wing('down', aligned.right, rightDone)}
1001
+ </View>
1002
+ </View>
1003
+ )
1004
+ }
578
1005
 
1006
+ /**
1007
+ * The `compact` diverging chart — the resting dual FOLDED into ONE 8px strip. Because the compact
1008
+ * bars are flat (colour-encoded, not height-encoded), the diverging pair need not stack into two
1009
+ * rows: each rep column splits at the centre into an L (top) and R (bottom) flat half (≈3.25px each,
1010
+ * a {@link COMPACT_FOLD_GAP}px gap between — a gap, not a line), so the pair occupies the SAME 8px as
1011
+ * the single compact — the per-rep analogue of the north-star stacked-halves. Index-locked (a lagging
1012
+ * side's un-logged reps render as faint empties); no gutter / labels / axis (no room at 8px). The
1013
+ * value-height diverging (where bar length must encode velocity) lives in `hero` / `rail`.
1014
+ */
1015
+ function DualVelocityCompactStrip({
1016
+ leftDone,
1017
+ rightDone,
1018
+ leftStream,
1019
+ rightStream,
1020
+ zones,
1021
+ barColor,
1022
+ targetReps,
1023
+ height,
1024
+ className,
1025
+ label,
1026
+ viewProps,
1027
+ }: DualChartProps) {
1028
+ // COMPOSED, like the hero and rail: two `compact` strips at COMPACT_FOLD_HALF each, separated by
1029
+ // COMPACT_FOLD_GAP (5 + 1.5 + 5 = DUAL_COMPACT_HEIGHT exactly). Because a compact bar FILLS its
1030
+ // plot, each column reads as an L top pill over an R bottom pill — the folded design, but drawn
1031
+ // by the same SetBarChart the single uses. Composing rather than hand-rolling is what makes the
1032
+ // set-type vocabulary (to-do, the cyan variable/continue windows, drop chunk-notch) and the
1033
+ // index-locked empty arrive for free, and keeps them from drifting apart later.
1034
+ const half = height === DUAL_COMPACT_HEIGHT ? COMPACT_FOLD_HALF : (height - COMPACT_FOLD_GAP) / 2
1035
+ const aligned = alignDualSlots(
1036
+ leftStream ? streamNaturalSlots(leftStream, targetReps) : [],
1037
+ rightStream ? streamNaturalSlots(rightStream, targetReps) : []
1038
+ )
579
1039
  const { style: externalStyle, ...restProps } = viewProps
580
1040
 
1041
+ const wing = (orientation: 'up' | 'down', columns: SetSlot[], done: number[]) => (
1042
+ <VelocityStrip
1043
+ variant="compact"
1044
+ orientation={orientation}
1045
+ velocities={done}
1046
+ columnSlots={columns}
1047
+ barColor={barColor}
1048
+ zones={zones}
1049
+ height={half}
1050
+ />
1051
+ )
1052
+
581
1053
  return (
582
1054
  <View
583
1055
  className={className}
584
- style={[{ height }, externalStyle]}
1056
+ style={[{ height, flexDirection: 'column', gap: COMPACT_FOLD_GAP }, externalStyle]}
585
1057
  accessibilityRole="image"
586
1058
  accessibilityLabel={label}
587
- testID="velocity-strip-hero"
1059
+ testID="dual-velocity-strip"
588
1060
  {...restProps}
589
1061
  >
590
- <View
591
- onLayout={onPlotLayout}
592
- style={{
593
- flex: 1,
594
- flexDirection: 'row',
595
- alignItems: 'flex-end',
596
- gap: heroGap,
597
- position: 'relative',
598
- borderBottomWidth: 2,
599
- borderBottomColor: HERO_PENDING_COLOR,
600
- }}
601
- >
602
- {/*
603
- * Running-best reference line. Deliberately unlabeled: it sits at the tallest
604
- * bar's top, and that bar already displays its velocity as its own value label,
605
- * so a "best X.XX" tag would duplicate it — and would collide with that label
606
- * whenever the peak bar is on the same side (declines peak-left, ends-on-best
607
- * peak-right). The line alone reads as "how far has velocity fallen from best".
608
- * The numeric best is in the container's accessibility label.
609
- */}
610
- {referencePx > 0 && (
611
- <View
612
- accessibilityElementsHidden
613
- style={{
614
- position: 'absolute',
615
- left: 0,
616
- right: 0,
617
- bottom: referencePx,
618
- borderTopWidth: 1,
619
- borderStyle: 'dashed',
620
- borderColor: HERO_REFERENCE_COLOR,
621
- pointerEvents: 'none',
622
- }}
623
- testID="velocity-hero-reference"
624
- />
625
- )}
626
-
627
- {doneVelocities.map((velocity, i) => {
628
- const isLive = liveRepIndex === i
629
- return (
630
- <View
631
- key={i}
632
- accessibilityElementsHidden
633
- style={{
634
- flex: 1,
635
- maxWidth: HERO_BAR_MAX_WIDTH,
636
- height: '100%',
637
- alignItems: 'center',
638
- justifyContent: 'flex-end',
639
- }}
640
- >
641
- {showBarLabel(i) && (
642
- <Text
643
- className="text-text-primary"
644
- style={{ fontSize: 12, fontWeight: '800', marginBottom: 4 }}
645
- testID={`velocity-label-${i}`}
646
- >
647
- {formatVelocity(velocity)}
648
- </Text>
649
- )}
650
- <Animated.View
651
- style={[
652
- {
653
- width: '100%',
654
- height: barHeight(velocity),
655
- borderTopLeftRadius: HERO_BAR_RADIUS,
656
- borderTopRightRadius: HERO_BAR_RADIUS,
657
- backgroundColor: barColorFor(velocity),
658
- },
659
- isLive ? { transform: [{ scale: liveScale }] } : null,
660
- ]}
661
- testID={`velocity-bar-${i}`}
662
- />
663
- </View>
664
- )
665
- })}
666
-
667
- {Array.from({ length: pendingCount }, (_, i) => (
668
- <View
669
- key={`pending-${i}`}
670
- accessibilityElementsHidden
671
- style={{
672
- flex: 1,
673
- maxWidth: HERO_BAR_MAX_WIDTH,
674
- height: '100%',
675
- alignItems: 'center',
676
- justifyContent: 'flex-end',
677
- }}
678
- >
679
- <View
680
- style={{
681
- width: '100%',
682
- height: HERO_MIN_BAR_HEIGHT * 3,
683
- borderWidth: 1,
684
- borderStyle: 'dashed',
685
- borderColor: HERO_PENDING_COLOR,
686
- borderTopLeftRadius: HERO_BAR_RADIUS,
687
- borderTopRightRadius: HERO_BAR_RADIUS,
688
- }}
689
- testID="velocity-slot-todo"
690
- />
691
- </View>
692
- ))}
1062
+ <View accessibilityElementsHidden testID="dual-velocity-wing-up">
1063
+ {wing('up', aligned.left, leftDone)}
1064
+ </View>
1065
+ <View accessibilityElementsHidden testID="dual-velocity-wing-down">
1066
+ {wing('down', aligned.right, rightDone)}
693
1067
  </View>
694
1068
  </View>
695
1069
  )
696
1070
  }
697
1071
 
1072
+ /**
1073
+ * The dual-voltra (bilateral) DIVERGING per-rep velocity chart. The up-wing stream grows UP, the
1074
+ * down-wing stream grows DOWN from one shared centre axis. The `hero` variant COMPOSES two single
1075
+ * {@link VelocityStrip} heroes (see {@link DualVelocityHero}) so any hero improvement reaches the
1076
+ * dual for free; `rail` uses a lean value-height renderer ({@link DualVelocityRail}); `compact` folds
1077
+ * the pair into one 8px strip ({@link DualVelocityCompactStrip}). Single-voltra sets keep using
1078
+ * {@link VelocityStrip} (`variant="hero"`) — unchanged.
1079
+ */
1080
+ export function DualVelocityStrip({
1081
+ left,
1082
+ right,
1083
+ zones,
1084
+ barColor = 'loss',
1085
+ targetReps,
1086
+ liveRepIndex,
1087
+ variant = 'hero',
1088
+ scale = 'peak',
1089
+ height,
1090
+ className,
1091
+ ...props
1092
+ }: DualVelocityStripProps) {
1093
+ const resolvedHeight =
1094
+ height ??
1095
+ (variant === 'hero'
1096
+ ? DUAL_HERO_HEIGHT
1097
+ : variant === 'compact'
1098
+ ? DUAL_COMPACT_HEIGHT
1099
+ : DUAL_RAIL_HEIGHT)
1100
+ // The done arrays drive the shared max + per-side best + the summary counts; the raw streams
1101
+ // flow to the hero wings so a side's set-type WINDOWS render (the `rail` renderer uses `*Done`).
1102
+ const leftDone = streamDone(left)
1103
+ const rightDone = streamDone(right)
1104
+
1105
+ const label =
1106
+ `Dual velocity chart, left ${leftDone.length} of ${Math.max(leftDone.length, targetReps ?? leftDone.length)} reps, ` +
1107
+ `right ${rightDone.length} of ${Math.max(rightDone.length, targetReps ?? rightDone.length)} reps`
1108
+
1109
+ const shared: DualChartProps = {
1110
+ leftDone,
1111
+ rightDone,
1112
+ leftStream: left,
1113
+ rightStream: right,
1114
+ leftLabel: left.label,
1115
+ rightLabel: right.label,
1116
+ zones,
1117
+ barColor,
1118
+ scale,
1119
+ targetReps,
1120
+ height: resolvedHeight,
1121
+ className,
1122
+ label,
1123
+ viewProps: props,
1124
+ }
1125
+
1126
+ // `liveRepIndex` reaches the rail too: its wings are composed strips, so the newest rep grows
1127
+ // from the midline on BOTH sides. Compact is flat, so a grow animation has nothing to animate.
1128
+ if (variant === 'rail') return <DualVelocityRail {...shared} liveRepIndex={liveRepIndex} />
1129
+ if (variant === 'compact') return <DualVelocityCompactStrip {...shared} />
1130
+ return <DualVelocityHero {...shared} liveRepIndex={liveRepIndex} />
1131
+ }
1132
+
698
1133
  export function VelocityStrip({
699
1134
  velocities,
700
1135
  set,
701
1136
  zones,
1137
+ barColor = 'loss',
1138
+ showLossBands,
1139
+ orientation = 'up',
1140
+ scaleMax,
1141
+ hideBaseline,
1142
+ columnSlots,
1143
+ label,
702
1144
  liveRepIndex,
703
1145
  expanded = true,
704
1146
  onToggle,
@@ -712,17 +1154,13 @@ export function VelocityStrip({
712
1154
  className,
713
1155
  ...props
714
1156
  }: VelocityStripProps) {
1157
+ // Bands default on with the loss bar-fill (the two halves of the loss language),
1158
+ // off for the absolute zone scale; an explicit prop always wins.
1159
+ const lossBandsOn = showLossBands ?? barColor === 'loss'
715
1160
  // A `set` descriptor derives its own done-velocity array; the legacy
716
1161
  // `velocities` path stays the source of truth otherwise. Every summary calc
717
1162
  // (mean / loss / zone) runs on this one array so the info row works either way.
718
1163
  const doneVelocities = set ? deriveDoneVelocities(set) : (velocities ?? [])
719
- const slots: VelocitySlot[] = set
720
- ? buildSlots(set)
721
- : doneVelocities.map((v, i) => ({
722
- kind: 'rep',
723
- velocity: v,
724
- leadingGap: i === 0 ? 0 : REP_GAP,
725
- }))
726
1164
 
727
1165
  const maxVelocity = Math.max(...doneVelocities, 0)
728
1166
  const meanVelocity = calculateMeanVelocity(doneVelocities)
@@ -731,50 +1169,37 @@ export function VelocityStrip({
731
1169
  // The framed chart (raised box, labels, info) vs the bare spotlight strip is the
732
1170
  // only fork in the `expanded` variant — keyed by whether any chrome is requested.
733
1171
  const framed = showNumbers || showInfo
734
- const scaleDenom = scaleDenominator(scale, maxVelocity)
735
1172
 
736
1173
  const hasZones = zones != null && zones.length > 0
1174
+ // Single-sourced zone resolver (diverging-hero) wrapped with the loss-relative mode: `barColor="loss"`
1175
+ // colors each rep by its velocity loss from the set's own best, else the shared zone scale. Every
1176
+ // variant hands this `colorFor` to SetBarChart, so all variants color identically.
1177
+ const zoneColorFor = makeBarColorFor(zones)
737
1178
  const barColorFor = (v: number): string =>
738
- hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
739
- const slotColor = (slot: VelocitySlot): string => {
740
- if (slot.kind === 'rep') return barColorFor(slot.velocity ?? 0)
741
- if (slot.kind === 'todo') return TODO_COLOR
742
- return SET_STRIP_VARIABLE_COLOR
743
- }
1179
+ barColor === 'loss' ? getVelocityLossColor(velocityLossForRep(v, maxVelocity)) : zoneColorFor(v)
744
1180
  const meanZone = hasZones
745
1181
  ? (classifyBand(meanVelocity, zones)?.label ?? '')
746
1182
  : getVelocityZoneName(meanVelocity)
747
1183
 
748
- const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
749
- const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
1184
+ // The framed collapse is now an IN-PLACE bar-height morph: `expandProgress` (0 collapsed → 1 open)
1185
+ // drives SetBarChart's bars flat↔value (no reflow), replacing the old collapse-to-3px height anim.
1186
+ // `infoOpacity` fades the info row; the per-bar labels fade with `expandProgress` in the overlay.
1187
+ const [expandProgress] = useState(() => new Animated.Value(expanded ? 1 : 0))
750
1188
  const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
751
1189
 
752
1190
  // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
753
1191
  const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
754
1192
  const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
755
- const liveScale = useLiveRepPop(
756
- variant === 'expanded' && framed,
757
- liveRepIndex,
758
- liveVelocity,
759
- isNewPeak
760
- )
761
1193
 
762
1194
  useEffect(() => {
763
1195
  if (variant !== 'expanded' || !framed) return
764
-
765
1196
  Animated.parallel([
766
- Animated.timing(heightAnim, {
767
- toValue: expanded ? height : 3,
1197
+ Animated.timing(expandProgress, {
1198
+ toValue: expanded ? 1 : 0,
768
1199
  duration: ANIMATION_DURATION,
769
1200
  easing: ANIMATION_EASING,
770
1201
  useNativeDriver: false,
771
1202
  }),
772
- Animated.timing(labelOpacity, {
773
- toValue: expanded ? 1 : 0,
774
- duration: 300,
775
- delay: expanded ? 150 : 0,
776
- useNativeDriver: false,
777
- }),
778
1203
  Animated.timing(infoOpacity, {
779
1204
  toValue: expanded ? 1 : 0,
780
1205
  duration: 300,
@@ -782,7 +1207,7 @@ export function VelocityStrip({
782
1207
  useNativeDriver: false,
783
1208
  }),
784
1209
  ]).start()
785
- }, [expanded, variant, framed, height, heightAnim, labelOpacity, infoOpacity])
1210
+ }, [expanded, variant, framed, expandProgress, infoOpacity])
786
1211
 
787
1212
  // Nothing to draw: neither a legacy velocity array nor a set descriptor.
788
1213
  if (set == null && velocities == null) return null
@@ -791,106 +1216,129 @@ export function VelocityStrip({
791
1216
  const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
792
1217
 
793
1218
  if (variant === 'hero') {
794
- // Hero's plot is far taller than the expanded chart; apply its own default when
795
- // the caller left `height` at the shared 60px default.
796
- const heroHeight = height === EXPANDED_HEIGHT ? HERO_HEIGHT : height
1219
+ // Hero's plot is far taller than the expanded chart; apply its own default when the caller left
1220
+ // `height` at the shared 60px default. The diverging dual (which passes `columnSlots`) sets an
1221
+ // explicit per-wing height exempt it, so a dual whose wing height happens to be 60px (a 120px
1222
+ // dual) isn't mistaken for "unset" and blown up to 220.
1223
+ const heroHeight =
1224
+ height === EXPANDED_HEIGHT && columnSlots == null ? SET_BAR_DEFAULT_HEIGHT : height
1225
+ // `columnSlots` (the diverging dual's shared index-locked structure) wins; else a `set` builds
1226
+ // its own typed slots, and the plain `velocities` path is bare rep slots + a `targetReps` remainder.
1227
+ const heroSlots: SetSlot[] =
1228
+ columnSlots ??
1229
+ (set
1230
+ ? buildSlots(set).map((s) => ({
1231
+ kind: s.kind,
1232
+ value: s.velocity,
1233
+ leadingGap: s.leadingGap,
1234
+ }))
1235
+ : doneVelocities.map((v) => ({ kind: 'rep', value: v })))
1236
+ const total = Math.max(repCount, targetReps ?? repCount)
1237
+ const heroLabel =
1238
+ maxVelocity > 0
1239
+ ? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(maxVelocity)} meters per second`
1240
+ : `Velocity chart, ${repCount} of ${total} reps`
797
1241
  return (
798
- <HeroVelocityChart
799
- doneVelocities={doneVelocities}
800
- barColorFor={barColorFor}
801
- scaleDenom={scaleDenom}
802
- referenceVelocity={maxVelocity}
1242
+ <SetBarChart
1243
+ slots={heroSlots}
1244
+ colorFor={barColorFor}
1245
+ height={heroHeight}
1246
+ scale={scale}
1247
+ scaleMax={scaleMax}
1248
+ orientation={orientation}
803
1249
  liveRepIndex={liveRepIndex}
804
1250
  isNewPeak={isNewPeak}
805
- targetReps={targetReps}
806
- height={heroHeight}
1251
+ targetReps={set || columnSlots ? undefined : targetReps}
1252
+ label={label}
1253
+ showValueLabels
1254
+ formatValue={formatVelocity}
1255
+ renderReference={(g) => velocityReferenceOverlay(g, lossBandsOn)}
1256
+ hideBaseline
1257
+ testID="velocity-strip-hero"
1258
+ testIDPrefix="velocity"
1259
+ accessibilityLabel={heroLabel}
807
1260
  className={className}
808
1261
  viewProps={props}
809
1262
  />
810
1263
  )
811
1264
  }
812
1265
 
813
- if (variant === 'mini') {
814
- const { style: externalStyle, ...restProps } = props
815
- // The container keeps the uniform REP_GAP (so a no-`set` strip is byte-identical
816
- // to prior releases and holds HTML-parity); per-slot `marginLeft` carries only
817
- // the EXTRA spacing for the WIDE notch that chunks drop / myo / cluster sets.
1266
+ if (variant === 'compact') {
1267
+ // `compact` = the flat resting form: SetBarChart in FLAT mode (uniform short bars, no value
1268
+ // labels), sharing hero's geometry same colors, paper, 0.08 spacing, proportional chunk-notch,
1269
+ // todo/variable/continue slots, and gutter so a compact↔expanded toggle only changes bar HEIGHT.
1270
+ // `columnSlots` (the diverging dual-compact's shared index-locked structure) wins, like the hero.
1271
+ const compactSlots: SetSlot[] =
1272
+ columnSlots ??
1273
+ (set
1274
+ ? buildSlots(set).map((s) => ({
1275
+ kind: s.kind,
1276
+ value: s.velocity,
1277
+ leadingGap: s.leadingGap,
1278
+ }))
1279
+ : doneVelocities.map((v) => ({ kind: 'rep', value: v })))
1280
+ const compactHeight = height === EXPANDED_HEIGHT ? COMPACT_HEIGHT : height
818
1281
  return (
819
- <View
820
- className={className}
821
- style={[
822
- { flexDirection: 'row', height: 3, gap: REP_GAP, borderRadius: 2, overflow: 'hidden' },
823
- externalStyle,
824
- ]}
825
- accessibilityRole="image"
1282
+ <SetBarChart
1283
+ slots={compactSlots}
1284
+ colorFor={barColorFor}
1285
+ height={compactHeight}
1286
+ scale={scale}
1287
+ scaleMax={scaleMax}
1288
+ orientation={orientation}
1289
+ flat
1290
+ barRadius={2}
1291
+ cornerStyle="all"
1292
+ targetReps={set || columnSlots ? undefined : targetReps}
1293
+ label={label}
1294
+ hideBaseline
1295
+ testID="velocity-strip-compact"
1296
+ testIDPrefix="velocity"
826
1297
  accessibilityLabel={miniLabel}
827
- testID="velocity-strip-mini"
828
- {...restProps}
829
- >
830
- {slots.map((slot, i) => (
831
- <View
832
- key={i}
833
- style={{
834
- flex: 1,
835
- backgroundColor: slotColor(slot),
836
- borderRadius: 1,
837
- minWidth: 4,
838
- height: '100%' as unknown as number,
839
- // The container's uniform 2px gap covers rep spacing; a wide slot adds
840
- // only the EXTRA (WIDE_GAP − REP_GAP) so the notch totals WIDE_GAP.
841
- marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
842
- ...(slot.kind === 'continue'
843
- ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
844
- : {}),
845
- }}
846
- accessibilityElementsHidden
847
- testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
848
- />
849
- ))}
850
- </View>
1298
+ className={className}
1299
+ viewProps={props}
1300
+ />
851
1301
  )
852
1302
  }
853
1303
 
854
- // Bare `expanded` strip (both chrome flags off): the velocity-HEIGHT spotlight —
855
- // px bar heights, no raised box / labels / info row / animation. Set-type gaps carry
856
- // over from the slot model exactly as in `mini` (container REP_GAP + per-slot extra
857
- // for a WIDE notch); planned / variable / continue slots draw as stubs.
1304
+ // Bare `expanded` strip (both chrome flags off): the velocity-HEIGHT spotlight — now
1305
+ // FOLDED onto SetBarChart (value mode, no labels), so its bars share the SAME geometry
1306
+ // (widths / gaps / chunk-notch / slots / paper) as compact + hero. Only the height-mode
1307
+ // (value here, flat in compact) differs, so a compact↔spotlight toggle never reflows.
858
1308
  if (!framed) {
859
- const { style: externalStyle, ...restProps } = props
1309
+ // `columnSlots` wins, exactly as it does for compact and hero. The diverging dual passes the
1310
+ // index-locked shared structure through here; ignoring it gave the expanded dual its own
1311
+ // per-side columns, so a lagging side rendered FEWER bars instead of an aligned empty cell.
1312
+ const spotlightSlots: SetSlot[] =
1313
+ columnSlots ??
1314
+ (set
1315
+ ? buildSlots(set).map((s) => ({
1316
+ kind: s.kind,
1317
+ value: s.velocity,
1318
+ leadingGap: s.leadingGap,
1319
+ }))
1320
+ : doneVelocities.map((v) => ({ kind: 'rep', value: v })))
860
1321
  return (
861
- <View
862
- className={className}
863
- style={[
864
- { flexDirection: 'row', height, gap: REP_GAP, alignItems: 'flex-end' },
865
- externalStyle,
866
- ]}
867
- accessibilityRole="image"
1322
+ <SetBarChart
1323
+ slots={spotlightSlots}
1324
+ colorFor={barColorFor}
1325
+ height={height}
1326
+ scale={scale}
1327
+ scaleMax={scaleMax}
1328
+ orientation={orientation}
1329
+ liveRepIndex={liveRepIndex}
1330
+ isNewPeak={isNewPeak}
1331
+ barRadius={2}
1332
+ cornerStyle="top"
1333
+ targetReps={set ? undefined : targetReps}
1334
+ label={label}
1335
+ hideBaseline
1336
+ testID="velocity-strip-spotlight"
1337
+ testIDPrefix="velocity"
868
1338
  accessibilityLabel={miniLabel}
869
- testID="velocity-strip-compact"
870
- {...restProps}
871
- >
872
- {slots.map((slot, i) => (
873
- <View
874
- key={i}
875
- style={{
876
- flex: 1,
877
- minWidth: 4,
878
- height: bareSlotHeight(slot, height, scaleDenom),
879
- // Top-rounded to match the framed chart's bars (the "rounded tops from
880
- // the numbers one"); square bottoms since bars sit on the baseline.
881
- borderTopLeftRadius: 2,
882
- borderTopRightRadius: 2,
883
- backgroundColor: slotColor(slot),
884
- marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
885
- ...(slot.kind === 'continue'
886
- ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
887
- : {}),
888
- }}
889
- accessibilityElementsHidden
890
- testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
891
- />
892
- ))}
893
- </View>
1339
+ className={className}
1340
+ viewProps={props}
1341
+ />
894
1342
  )
895
1343
  }
896
1344
 
@@ -901,204 +1349,100 @@ export function VelocityStrip({
901
1349
  const hasInteractiveContainer = onToggle != null
902
1350
  const hasInteractiveReps = onRepPress != null && expanded
903
1351
 
904
- // Expanded set-type bars: `straight` is the active-set spotlight (velocity-height
905
- // done reps + short grey planned stubs); the advanced types render as a short,
906
- // mini-style encoding (the gaps + colors carry identity, height is uniform).
907
- const setBars =
908
- set != null &&
909
- slots.map((slot, i) => {
910
- const isStraightRep = set.type === 'straight' && slot.kind === 'rep'
911
- const heightPct = isStraightRep
912
- ? scaleDenom > 0
913
- ? Math.round(((slot.velocity ?? 0) / scaleDenom) * 100)
914
- : 0
915
- : set.type === 'straight'
916
- ? EXPANDED_TODO_STUB_PCT
917
- : EXPANDED_ENCODED_PCT
918
- return (
919
- <View
920
- key={i}
921
- style={{
922
- flex: 1,
923
- height: '100%',
924
- justifyContent: 'flex-end',
925
- marginLeft: slot.leadingGap,
926
- }}
927
- >
928
- <View
929
- style={{
930
- height: `${heightPct}%`,
931
- minHeight: 2,
932
- borderTopLeftRadius: 2,
933
- borderTopRightRadius: 2,
934
- backgroundColor: slotColor(slot),
935
- ...(slot.kind === 'continue'
936
- ? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
937
- : {}),
938
- }}
939
- testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
940
- accessibilityRole="image"
941
- accessibilityLabel={slotAccessibilityLabel(slot, i)}
942
- />
943
- </View>
1352
+ const framedSlots: SetSlot[] = set
1353
+ ? buildSlots(set).map((sl) => ({
1354
+ kind: sl.kind,
1355
+ value: sl.velocity,
1356
+ leadingGap: sl.leadingGap,
1357
+ }))
1358
+ : doneVelocities.map((v) => ({ kind: 'rep', value: v }))
1359
+
1360
+ // Per-bar overlay handed to SetBarChart so the framed chart keeps its m/s label (fading with the
1361
+ // expand) + its onRepPress hit-target WITHOUT re-rolling bars — one bar-rendering path remains.
1362
+ const needsBarOverlay = showNumbers || onRepPress != null
1363
+ const renderFramedBarOverlay = needsBarOverlay
1364
+ ? (repIndex: number, value: number) => (
1365
+ <>
1366
+ {showNumbers && (
1367
+ <Animated.View
1368
+ style={{
1369
+ opacity: expandProgress,
1370
+ position: 'absolute',
1371
+ top: -13,
1372
+ left: 0,
1373
+ right: 0,
1374
+ alignItems: 'center',
1375
+ }}
1376
+ accessibilityElementsHidden
1377
+ pointerEvents="none"
1378
+ >
1379
+ <Text
1380
+ className="text-text-secondary"
1381
+ style={{ fontSize: 8, fontWeight: '600' }}
1382
+ testID={`velocity-label-${repIndex}`}
1383
+ >
1384
+ {formatVelocity(value)}
1385
+ </Text>
1386
+ </Animated.View>
1387
+ )}
1388
+ {onRepPress && expanded && (
1389
+ <Pressable
1390
+ style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}
1391
+ onPress={() => onRepPress(repIndex, value)}
1392
+ accessibilityRole="button"
1393
+ accessibilityLabel={`Rep ${repIndex + 1}: ${formatVelocity(value)} meters per second, tap for details`}
1394
+ testID={`velocity-bar-pressable-${repIndex}`}
1395
+ />
1396
+ )}
1397
+ </>
944
1398
  )
945
- })
1399
+ : undefined
946
1400
 
1401
+ // The framed chrome is a WRAPPER (raised box + info row + tap-to-collapse) around ONE SetBarChart
1402
+ // in value mode; the collapse is the in-place `expandProgress` bar-height morph, not a height strip.
947
1403
  const stripContent = (
948
1404
  <Animated.View
949
- className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
950
- .filter(Boolean)
951
- .join(' ')}
952
- style={[
953
- {
954
- height: heightAnim,
955
- width: '100%',
956
- gap: 2,
957
- borderRadius: expanded ? 6 : 2,
958
- paddingTop: expanded ? 16 : 0,
959
- paddingBottom: expanded ? 24 : 0,
960
- // No horizontal inset — the framed chart's bars span full width, matching
961
- // the bare spotlight and mini. Numbers/info sit in the vertical padding.
962
- paddingHorizontal: 0,
963
- paddingVertical: expanded ? undefined : 8,
964
- overflow: expanded ? 'visible' : 'hidden',
965
- },
966
- ]}
1405
+ className={[className, 'bg-surface-raised'].filter(Boolean).join(' ')}
1406
+ style={{ width: '100%', borderRadius: 6, paddingTop: 16, paddingBottom: showInfo ? 8 : 4 }}
967
1407
  accessibilityRole={hasInteractiveContainer || hasInteractiveReps ? 'none' : 'button'}
968
1408
  accessibilityLabel={hasInteractiveContainer || hasInteractiveReps ? undefined : stripLabel}
969
1409
  testID="velocity-strip"
970
1410
  {...props}
971
1411
  >
972
- <View style={{ flexDirection: 'row', flex: 1, gap: set ? 0 : 2, alignItems: 'flex-end' }}>
973
- {set
974
- ? setBars
975
- : doneVelocities.map((v, i) => {
976
- const barBackground = barColorFor(v)
977
- // Guard all-zero velocities (idle / pre-rep): a 0 denominator makes
978
- // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
979
- const barHeightPct = scaleDenom > 0 ? Math.round((v / scaleDenom) * 100) : 0
980
- const isLive = liveRepIndex === i
981
- const liveLabelSuffix = isLive
982
- ? isNewPeak
983
- ? ', latest rep, new set peak'
984
- : ', latest rep'
985
- : ''
986
-
987
- const barStyle: ViewStyle = expanded
988
- ? {
989
- height: `${barHeightPct}%`,
990
- minHeight: 2,
991
- borderTopLeftRadius: 2,
992
- borderTopRightRadius: 2,
993
- backgroundColor: barBackground,
994
- }
995
- : {
996
- minHeight: '100%' as unknown as number,
997
- borderTopLeftRadius: 2,
998
- borderTopRightRadius: 2,
999
- borderBottomLeftRadius: 0,
1000
- borderBottomRightRadius: 0,
1001
- backgroundColor: barBackground,
1002
- }
1003
-
1004
- const barInner = isLive ? (
1005
- <Animated.View
1006
- style={[barStyle, { transform: [{ scale: liveScale }] }]}
1007
- testID={`velocity-bar-${i}`}
1008
- accessibilityRole="image"
1009
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
1010
- />
1011
- ) : (
1012
- <View
1013
- style={barStyle}
1014
- testID={`velocity-bar-${i}`}
1015
- accessibilityRole="image"
1016
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
1017
- />
1018
- )
1019
-
1020
- const bar = (
1021
- <View
1022
- key={i}
1023
- style={{
1024
- flex: 1,
1025
- height: '100%',
1026
- justifyContent: 'flex-end',
1027
- position: 'relative',
1028
- }}
1029
- >
1030
- {expanded && showNumbers && (
1031
- <Animated.View
1032
- style={{
1033
- opacity: labelOpacity,
1034
- alignItems: 'center',
1035
- position: 'absolute',
1036
- top: -13,
1037
- left: 0,
1038
- right: 0,
1039
- }}
1040
- accessibilityElementsHidden
1041
- >
1042
- <Text
1043
- className="text-text-secondary"
1044
- style={{ fontSize: 8, fontWeight: '600' }}
1045
- testID={`velocity-label-${i}`}
1046
- >
1047
- {formatVelocity(v)}
1048
- </Text>
1049
- </Animated.View>
1050
- )}
1051
- {barInner}
1052
- </View>
1053
- )
1054
-
1055
- if (onRepPress && expanded) {
1056
- return (
1057
- <Pressable
1058
- key={i}
1059
- style={{ flex: 1, height: '100%' }}
1060
- onPress={() => onRepPress(i, v)}
1061
- accessibilityRole="button"
1062
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
1063
- testID={`velocity-bar-pressable-${i}`}
1064
- >
1065
- {bar}
1066
- </Pressable>
1067
- )
1068
- }
1069
-
1070
- return bar
1071
- })}
1072
- </View>
1412
+ <SetBarChart
1413
+ slots={framedSlots}
1414
+ colorFor={barColorFor}
1415
+ height={height}
1416
+ scale={scale}
1417
+ scaleMax={scaleMax}
1418
+ expandProgress={expandProgress}
1419
+ renderBarOverlay={renderFramedBarOverlay}
1420
+ targetReps={set ? undefined : targetReps}
1421
+ barRadius={2}
1422
+ cornerStyle="top"
1423
+ hideBaseline
1424
+ testIDPrefix="velocity"
1425
+ />
1073
1426
  {expanded && showInfo && (
1074
1427
  <Animated.View
1075
1428
  style={{
1076
1429
  flexDirection: 'row',
1077
1430
  justifyContent: 'space-between',
1078
1431
  opacity: infoOpacity,
1079
- position: 'absolute',
1080
- bottom: 4,
1081
- left: 6,
1082
- right: 6,
1432
+ marginTop: 6,
1433
+ paddingHorizontal: 6,
1083
1434
  }}
1084
1435
  testID="velocity-info-row"
1085
1436
  >
1086
1437
  <Text
1087
1438
  className="text-text-secondary"
1088
- style={{
1089
- fontSize: 10,
1090
- fontFamily: 'Inter, sans-serif',
1091
- }}
1439
+ style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}
1092
1440
  >
1093
1441
  {meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
1094
1442
  </Text>
1095
1443
  <Text
1096
1444
  className="text-text-secondary"
1097
- style={{
1098
- fontSize: 10,
1099
- fontFamily: 'Inter, sans-serif',
1100
- ...getLossStyle(loss),
1101
- }}
1445
+ style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', ...getLossStyle(loss) }}
1102
1446
  >
1103
1447
  Loss: {loss}%
1104
1448
  </Text>