@titan-design/react-ui 0.8.0 → 0.9.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 (34) hide show
  1. package/dist/index.d.mts +19 -59
  2. package/dist/index.d.ts +19 -59
  3. package/dist/index.js +248 -371
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +249 -368
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DCFBqp79.d.mts → pages-D7hOD4Vj.d.mts} +1 -1
  8. package/dist/{pages-_fI3-x7Z.d.ts → pages-DyEx-lBf.d.ts} +1 -1
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +199 -161
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +199 -161
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
  17. package/src/components/custom/Workout/README.md +33 -13
  18. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  19. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  20. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  21. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  22. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  23. package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
  24. package/src/components/custom/Workout/VelocityStrip.tsx +45 -11
  25. package/src/components/custom/Workout/ZoneTrack.tsx +15 -1
  26. package/src/components/custom/Workout/index.ts +1 -9
  27. package/src/lab/north-star/LivePage.tsx +101 -0
  28. package/src/lab/north-star/LiveView.tsx +409 -0
  29. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  30. package/src/lab/north-star/RestView.tsx +140 -0
  31. package/src/lab/north-star/fixtures.ts +224 -0
  32. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -164
  33. package/src/components/custom/Workout/TempoBar.test.tsx +0 -218
  34. package/src/components/custom/Workout/TempoBar.tsx +0 -300
@@ -4,7 +4,7 @@ import { View, Text, Pressable, type ViewProps } from 'react-native'
4
4
  import { roundTempo } from '../../../utils/workout-format'
5
5
  import { alpha } from '../../../utils/colors'
6
6
  import { getSemanticColors } from '../../../theme/tokens/semantic'
7
- import { getTempoFillPct, getTempoPacingState } from './TempoBar'
7
+ import { primitiveRamps } from '../../../theme/tokens/primitives'
8
8
  import { MetricCell } from './metricText'
9
9
 
10
10
  const t = getSemanticColors('dark')
@@ -22,14 +22,20 @@ export interface TempoLiveState {
22
22
  activePhase: TempoLivePhase | null
23
23
  /** Elapsed time (ms) within the active phase. */
24
24
  phaseElapsedMs: number
25
+ /**
26
+ * Actual completed durations (ms) for phases finished earlier THIS rep. A done cell
27
+ * freezes at its final readout (not the target) until the next rep resets the row.
28
+ * Omit a phase to fall back to its target (assume it completed on pace).
29
+ */
30
+ completed?: Partial<Record<TempoLivePhase, number>>
25
31
  }
26
32
 
27
33
  export interface TempoDisplayProps extends ViewProps {
28
34
  /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
29
35
  tempo: [number, number, number, number]
30
36
  size?: 'sm' | 'md'
31
- /** Colored phases or mono (all gray) */
32
- colored?: boolean
37
+ /** Override the digit font size (px). Defaults from `size` (9 sm / 11 md); raise for a wall read-out. */
38
+ fontSize?: number
33
39
  /** Show the "TEMPO" caption before the values. Default true. */
34
40
  showLabel?: boolean
35
41
  /** Show info tooltip on press */
@@ -40,19 +46,60 @@ export interface TempoDisplayProps extends ViewProps {
40
46
  * prescription string (default, backward compatible).
41
47
  */
42
48
  live?: TempoLiveState
49
+ /** Active-phase readout when `live`: `countdown` remaining to 0.0 (default), or `countup` elapsed. */
50
+ liveReadout?: TempoLiveReadout
43
51
  onPress?: () => void
44
52
  className?: string
45
53
  }
46
54
 
47
55
  const INTER = 'Inter, sans-serif'
48
56
  const TEXT_TERTIARY = '#6B7280'
49
- const STATUS_ERROR = t['status-error'] // shown when a phase runs behind pace
57
+ const STATUS_ERROR = t['status-error'] // slow over the target time
58
+ const STATUS_SUCCESS = t['status-success'] // on target (within the band of 0.0)
59
+ const STATUS_WARNING = t['status-warning'] // ahead — still time left to the target
60
+
61
+ /** Live active-phase readout: `countdown` remaining to 0.0, or `countup` elapsed to target. */
62
+ export type TempoLiveReadout = 'countdown' | 'countup'
63
+
64
+ /** ± this window (ms) around the target still counts as on target (0.0 ± 0.1s). */
65
+ const ON_TARGET_MS = 100
66
+
67
+ /** Phase-progress fill percent (0–100): how far the active phase has run toward its target. */
68
+ function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
69
+ if (!targetMs) return 100
70
+ return Math.min(100, (elapsedMs / targetMs) * 100)
71
+ }
72
+
73
+ /**
74
+ * Semantic pacing tone for the active/completed phase's NUMBER (the fill bar carries phase
75
+ * identity, so the number is free to carry pacing). Keyed on time remaining to target:
76
+ * ahead of target (still counting) → warning; within ±0.1s of 0.0 → success; over → error.
77
+ */
78
+ function activeNumberTone(elapsedMs: number, targetMs: number | null): string {
79
+ if (targetMs == null) return t['text-primary']
80
+ const remainingMs = targetMs - elapsedMs
81
+ if (remainingMs > ON_TARGET_MS) return STATUS_WARNING
82
+ if (remainingMs >= -ON_TARGET_MS) return STATUS_SUCCESS
83
+ return STATUS_ERROR
84
+ }
50
85
 
51
- // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
86
+ /** The active number: `countup` elapsed (→ target) or `countdown` remaining (→ 0.0, then −). */
87
+ function liveReadoutText(
88
+ elapsedMs: number,
89
+ targetMs: number,
90
+ readout: TempoLiveReadout,
91
+ ): string {
92
+ const seconds = readout === 'countup' ? elapsedMs / 1000 : (targetMs - elapsedMs) / 1000
93
+ return seconds.toFixed(1)
94
+ }
95
+
96
+ // Phase IDENTITY colours — deliberately NON-semantic (magenta ecc / cyan con) so the
97
+ // phase hue never collides with the semantic pacing tones (success/error) the active
98
+ // number carries. Pauses stay neutral grey. [eccentric, pauseBottom, concentric, pauseTop]
52
99
  const phaseColors = {
53
- eccentric: t['status-warning'],
100
+ eccentric: primitiveRamps.magenta[400],
54
101
  pauseBottom: TEXT_TERTIARY,
55
- concentric: t['status-success'],
102
+ concentric: primitiveRamps.cyan[300],
56
103
  pauseTop: TEXT_TERTIARY,
57
104
  dash: TEXT_TERTIARY,
58
105
  }
@@ -89,39 +136,95 @@ const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
89
136
  { key: 'pauseTop', color: phaseColors.pauseTop },
90
137
  ]
91
138
 
139
+ /** A phase's place in the current rep: already done (locked full), filling now, or still to come. */
140
+ type LiveCellStatus = 'done' | 'active' | 'upcoming'
141
+
142
+ /** A bottom-anchored vertical fill behind the number (the phase-progress bar). */
143
+ function CellFill({ color, pct }: { color: string; pct: number }) {
144
+ return (
145
+ <View
146
+ style={{
147
+ position: 'absolute',
148
+ left: 2,
149
+ right: 2,
150
+ bottom: 0,
151
+ height: `${pct}%`,
152
+ backgroundColor: alpha(color, 0.28),
153
+ borderRadius: 3,
154
+ }}
155
+ />
156
+ )
157
+ }
158
+
159
+ /** Monospace stack for the live readout so digit widths never shift. */
160
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
161
+ /** Widest readout the cell must hold: sign · two whole digits · point · tenths, e.g. "-12.3". */
162
+ const READOUT_MAX_CHARS = 5
163
+
92
164
  function LiveTempoCell({
93
165
  value,
94
166
  color,
95
- isActive,
167
+ status,
96
168
  phaseElapsedMs,
169
+ completedMs,
97
170
  fontSize,
171
+ readout,
98
172
  }: {
99
173
  value: number
100
174
  color: string
101
- isActive: boolean
175
+ status: LiveCellStatus
102
176
  phaseElapsedMs: number
177
+ completedMs: number | undefined
103
178
  fontSize: number
179
+ readout: TempoLiveReadout
104
180
  }) {
105
- if (!isActive) {
106
- return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
107
- }
108
181
  const targetMs = value > 0 ? value * 1000 : null
109
- const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
182
+ // Fixed, monospace-width cell sized to the widest readout a phase activating or its
183
+ // number changing never shifts the layout (the fill bar stays put too).
184
+ const cellW = Math.ceil(fontSize * 0.62 * READOUT_MAX_CHARS)
185
+ const wrap = {
186
+ position: 'relative' as const,
187
+ width: cellW,
188
+ minHeight: Math.round(fontSize * 1.25),
189
+ alignItems: 'center' as const,
190
+ justifyContent: 'center' as const,
191
+ }
192
+ const num = (c: string, text: string) => (
193
+ <Text style={{ fontFamily: MONO, fontSize, color: c, fontWeight: '700' }}>{text}</Text>
194
+ )
195
+
196
+ // Upcoming / idle: the phase's prescribed time in the FULL phase colour, no fill — so a
197
+ // not-yet-started (or fully idle) row just reads as the base tempo lockup, not a dim mode.
198
+ if (status === 'upcoming') {
199
+ const targetText = targetMs != null ? (targetMs / 1000).toFixed(1) : String(value)
200
+ return <View style={wrap}>{num(color, targetText)}</View>
201
+ }
202
+
203
+ // Done: FROZEN at the phase's final readout (its actual completed time, not the target),
204
+ // fill locked full in the phase colour. The number keeps its SEMANTIC pacing tone (it is
205
+ // showing the final time, not the target) — banked progress until the next rep resets.
206
+ if (status === 'done') {
207
+ const finalMs = completedMs ?? targetMs ?? 0
208
+ const finalText = targetMs != null ? liveReadoutText(finalMs, targetMs, readout) : String(value)
209
+ const finalTone = targetMs != null ? activeNumberTone(finalMs, targetMs) : color
210
+ return (
211
+ <View style={wrap} testID="tempo-live-done">
212
+ <CellFill color={color} pct={100} />
213
+ {num(finalTone, finalText)}
214
+ </View>
215
+ )
216
+ }
217
+
218
+ // Active: the phase-hued fill grows with the phase; the number reads the live time
219
+ // (countdown/countup) to 0.1s, coloured SEMANTICALLY by pacing (neutral/on-target/behind).
220
+ const numberTone = activeNumberTone(phaseElapsedMs, targetMs)
110
221
  const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
222
+ const activeText =
223
+ targetMs != null ? liveReadoutText(phaseElapsedMs, targetMs, readout) : String(value)
111
224
  return (
112
- <View style={{ position: 'relative' }} testID="tempo-live-active">
113
- <View
114
- style={{
115
- position: 'absolute',
116
- left: 0,
117
- top: 0,
118
- bottom: 0,
119
- width: `${fillPct}%`,
120
- backgroundColor: alpha(barColor, 0.3),
121
- borderRadius: 2,
122
- }}
123
- />
124
- <TempoValue value={value} color={barColor} fontSize={fontSize} />
225
+ <View style={wrap} testID="tempo-live-active">
226
+ <CellFill color={color} pct={fillPct} />
227
+ {num(numberTone, activeText)}
125
228
  </View>
126
229
  )
127
230
  }
@@ -130,25 +233,38 @@ function LiveTempoRow({
130
233
  values,
131
234
  live,
132
235
  fontSize,
236
+ readout,
133
237
  }: {
134
238
  values: [number, number, number, number]
135
239
  live: TempoLiveState
136
240
  fontSize: number
241
+ readout: TempoLiveReadout
137
242
  }) {
243
+ // Phases run in a fixed order within a rep, so the active phase's position tells us which
244
+ // phases are already done (locked). When it wraps back to the first phase, the row resets.
245
+ const activeIndex = live.activePhase
246
+ ? LIVE_PHASES.findIndex((p) => p.key === live.activePhase)
247
+ : -1
138
248
  return (
139
249
  <>
140
- {LIVE_PHASES.map((phase, i) => (
141
- <View key={phase.key} style={{ flexDirection: 'row' }}>
142
- {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
143
- <LiveTempoCell
144
- value={values[i]}
145
- color={phase.color}
146
- isActive={live.activePhase === phase.key}
147
- phaseElapsedMs={live.phaseElapsedMs}
148
- fontSize={fontSize}
149
- />
150
- </View>
151
- ))}
250
+ {LIVE_PHASES.map((phase, i) => {
251
+ const status: LiveCellStatus =
252
+ activeIndex < 0 || i > activeIndex ? 'upcoming' : i < activeIndex ? 'done' : 'active'
253
+ return (
254
+ <View key={phase.key} style={{ flexDirection: 'row', alignItems: 'center' }}>
255
+ {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
256
+ <LiveTempoCell
257
+ value={values[i]}
258
+ color={phase.color}
259
+ status={status}
260
+ phaseElapsedMs={live.phaseElapsedMs}
261
+ completedMs={live.completed?.[phase.key]}
262
+ fontSize={fontSize}
263
+ readout={readout}
264
+ />
265
+ </View>
266
+ )
267
+ })}
152
268
  </>
153
269
  )
154
270
  }
@@ -156,10 +272,11 @@ function LiveTempoRow({
156
272
  export function TempoDisplay({
157
273
  tempo,
158
274
  size = 'md',
159
- colored = true,
275
+ fontSize: fontSizeProp,
160
276
  showLabel = true,
161
277
  showInfo = true,
162
278
  live,
279
+ liveReadout = 'countdown',
163
280
  onPress,
164
281
  className,
165
282
  ...props
@@ -168,8 +285,13 @@ export function TempoDisplay({
168
285
  // Round exact (unrounded) tempo seconds to the 1-dp display granularity.
169
286
  const [eccentric, pauseBottom, concentric, pauseTop] = roundTempo(tempo)
170
287
  const isSm = size === 'sm'
171
- const fontSize = isSm ? 9 : 11
172
- const monoColor = TEXT_TERTIARY
288
+ const fontSize = fontSizeProp ?? (isSm ? 9 : 11)
289
+ // The chrome (padding, radius, label) scales with the digit size so the whole view stays
290
+ // proportional at any form factor — a compact rail chip up to a wall read-out.
291
+ const chromePadX = Math.round(fontSize * 0.6)
292
+ const chromePadY = Math.round(fontSize * 0.3)
293
+ const chromeRadius = Math.round(fontSize * 0.4)
294
+ const labelFont = Math.max(9, Math.round(fontSize * 0.5))
173
295
 
174
296
  const handlePress = useCallback(() => {
175
297
  if (onPress) {
@@ -187,9 +309,9 @@ export function TempoDisplay({
187
309
  flexDirection: 'row',
188
310
  alignItems: 'center',
189
311
  backgroundColor: '#1C1C1C',
190
- paddingHorizontal: isSm ? 6 : 8,
191
- paddingVertical: 3,
192
- borderRadius: 4,
312
+ paddingHorizontal: chromePadX,
313
+ paddingVertical: chromePadY,
314
+ borderRadius: chromeRadius,
193
315
  }}
194
316
  {...props}
195
317
  >
@@ -197,12 +319,13 @@ export function TempoDisplay({
197
319
  <Text
198
320
  style={{
199
321
  fontFamily: INTER,
200
- fontSize: 9,
322
+ fontSize: labelFont,
201
323
  fontWeight: '500',
202
- color: TEXT_TERTIARY,
324
+ // Live-muted green while a rep is running, tertiary at rest.
325
+ color: live ? t['status-live-muted'] : TEXT_TERTIARY,
203
326
  letterSpacing: 0.5,
204
327
  textTransform: 'uppercase',
205
- marginRight: 6,
328
+ marginRight: Math.round(fontSize * 0.5),
206
329
  }}
207
330
  >
208
331
  TEMPO
@@ -214,8 +337,10 @@ export function TempoDisplay({
214
337
  values={[eccentric, pauseBottom, concentric, pauseTop]}
215
338
  live={live}
216
339
  fontSize={fontSize}
340
+ readout={liveReadout}
217
341
  />
218
- ) : colored ? (
342
+ ) : (
343
+ // Static prescription: the phase-coloured lockup (the same colours the live row rests at).
219
344
  <>
220
345
  <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
221
346
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
@@ -225,18 +350,6 @@ export function TempoDisplay({
225
350
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
226
351
  <TempoValue value={pauseTop} color={phaseColors.pauseTop} fontSize={fontSize} />
227
352
  </>
228
- ) : (
229
- <Text
230
- style={{
231
- fontFamily: INTER,
232
- fontSize,
233
- color: monoColor,
234
- fontWeight: '600',
235
- letterSpacing: 1,
236
- }}
237
- >
238
- {eccentric}-{pauseBottom}-{concentric}-{pauseTop}
239
- </Text>
240
353
  )}
241
354
  </View>
242
355
  </View>
@@ -78,10 +78,10 @@ describe('VelocityStrip expanded (framed chart)', () => {
78
78
  })
79
79
 
80
80
  describe('scale', () => {
81
- it('peak (default) scales the tallest rep to full height', () => {
82
- // maxV=1.0 -> denom 1.15 -> 1.0/1.15 = 87%.
81
+ it('peak (default) scales the tallest rep to nearly full height', () => {
82
+ // maxV=1.0 -> denom 1.06 (peak headroom) -> 1.0/1.06 = 94%.
83
83
  render(<VelocityStrip velocities={[1.0, 0.5]} showInfo={false} />)
84
- expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '87%' })
84
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '94%' })
85
85
  })
86
86
 
87
87
  it('fixed scales against the 1.15 ceiling regardless of the set max', () => {
@@ -8,6 +8,7 @@ import {
8
8
  Easing,
9
9
  type ViewProps,
10
10
  type ViewStyle,
11
+ type LayoutChangeEvent,
11
12
  } from 'react-native'
12
13
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
13
14
  import { primitiveColors, primitiveRamps, sequentialEffort } from '../../../theme/tokens/primitives'
@@ -19,7 +20,7 @@ import { SET_STRIP_VARIABLE_COLOR } from './SetStrip'
19
20
  * (e.g. workout-analytics' `VelocityZones.bands`).
20
21
  *
21
22
  * Deliberately a plain structural shape — titan never imports the analytics
22
- * package (same policy as TempoBar's presentational phase key). Any object with
23
+ * package (the same presentational-only policy the Workout family follows). Any object with
23
24
  * this shape can be passed, so `zones={waVelocityZones.bands}` works directly.
24
25
  * Bands are ordered slow → fast, contiguous, and cover `[0, ∞)` with the top
25
26
  * band's `max === null`. Bands carry NO color — color is a UI concern resolved
@@ -393,7 +394,9 @@ const HERO_LABEL_HEADROOM = 20
393
394
  /** Gap between hero bars (px) — a group-notch-free even rhythm at wall scale. */
394
395
  const HERO_BAR_GAP = 8
395
396
  /** Cap on a single hero bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
396
- const HERO_BAR_MAX_WIDTH = 52
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
397
400
  /** Top-corner radius on hero bars (px). */
398
401
  const HERO_BAR_RADIUS = 5
399
402
  /** Minimum drawn height of a performed hero bar (px) — a near-zero rep still reads as a rep. */
@@ -403,9 +406,15 @@ const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
403
406
  /** The dashed running-best reference line + its label (the lightest charcoal step). */
404
407
  const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
405
408
 
409
+ /**
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).
412
+ */
413
+ const PEAK_HEADROOM = 1.06
414
+
406
415
  /** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
407
416
  function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
408
- return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * 1.15
417
+ return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * PEAK_HEADROOM
409
418
  }
410
419
 
411
420
  /** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
@@ -525,6 +534,11 @@ function HeroVelocityChart({
525
534
  const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
526
535
  const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
527
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
+
528
542
  // Reserve a band above the tallest bar for its value label so a peak bar never clips.
529
543
  const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
530
544
  const barHeight = (velocity: number): number =>
@@ -538,6 +552,23 @@ function HeroVelocityChart({
538
552
  ? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
539
553
  : 0
540
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,
568
+ )
569
+ const showBarLabel = (i: number): boolean =>
570
+ !labelsCrowded || i === peakIndex || i === liveRepIndex
571
+
541
572
  const repCount = doneVelocities.length
542
573
  const total = Math.max(repCount, targetReps ?? repCount)
543
574
  const label =
@@ -557,11 +588,12 @@ function HeroVelocityChart({
557
588
  {...restProps}
558
589
  >
559
590
  <View
591
+ onLayout={onPlotLayout}
560
592
  style={{
561
593
  flex: 1,
562
594
  flexDirection: 'row',
563
595
  alignItems: 'flex-end',
564
- gap: HERO_BAR_GAP,
596
+ gap: heroGap,
565
597
  position: 'relative',
566
598
  borderBottomWidth: 2,
567
599
  borderBottomColor: HERO_PENDING_COLOR,
@@ -606,13 +638,15 @@ function HeroVelocityChart({
606
638
  justifyContent: 'flex-end',
607
639
  }}
608
640
  >
609
- <Text
610
- className="text-text-primary"
611
- style={{ fontSize: 12, fontWeight: '800', marginBottom: 4 }}
612
- testID={`velocity-label-${i}`}
613
- >
614
- {formatVelocity(velocity)}
615
- </Text>
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
+ )}
616
650
  <Animated.View
617
651
  style={[
618
652
  {
@@ -1,5 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { View, Text, type ViewProps, type DimensionValue } from 'react-native'
2
+ import { useState } from 'react'
3
+ import { View, Text, type ViewProps, type DimensionValue, type LayoutChangeEvent } from 'react-native'
3
4
  import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
  import { primitiveColors } from '../../../theme/tokens/primitives'
5
6
  import { alpha } from '../../../utils/colors'
@@ -181,6 +182,17 @@ export function ZoneTrack({
181
182
  const s = ZONE_SIZES[size]
182
183
  const trackHeight = trackHeightProp ?? s.trackHeight
183
184
  const needleOverhang = needleOverhangProp ?? s.needleOverhang
185
+
186
+ // Thin the tick labels on a narrow track so they never overlap (each occupies a fixed
187
+ // `tickCellWidth` cell). Width 0 (unmeasured) → show all, so test/server renders match.
188
+ const [trackW, setTrackW] = useState(0)
189
+ const onTrackLayout = (e: LayoutChangeEvent) => setTrackW(e.nativeEvent.layout.width)
190
+ const tickCount = ticks?.length ?? 0
191
+ const tickSpacing = trackW > 0 && tickCount > 0 ? trackW / tickCount : Infinity
192
+ const tickStep = tickSpacing < s.tickCellWidth ? Math.ceil(s.tickCellWidth / tickSpacing) : 1
193
+ // Keep the first, last, evenly-stepped, and any emphasized ticks; drop the rest's labels.
194
+ const showTickLabel = (i: number, emphasized: boolean): boolean =>
195
+ tickStep <= 1 || i === 0 || i === tickCount - 1 || i % tickStep === 0 || emphasized
184
196
  const isNeedle = marker?.type === 'needle'
185
197
  const markerHeight = isNeedle ? trackHeight + needleOverhang * 2 : trackHeight
186
198
  const markerFrac = marker != null ? fraction(marker.value, min, max) : 0
@@ -200,6 +212,7 @@ export function ZoneTrack({
200
212
  accessibilityValue={marker != null ? { min, max, now: marker.value } : { min, max, now: min }}
201
213
  accessibilityLabel={accessibilityLabel ?? `Zone track: ${marker?.value ?? min}`}
202
214
  testID="zone-track"
215
+ onLayout={onTrackLayout}
203
216
  {...props}
204
217
  >
205
218
  <View style={{ position: 'relative', height: markerHeight }}>
@@ -329,6 +342,7 @@ export function ZoneTrack({
329
342
  {ticks.map((tick, i) => {
330
343
  if (tick.label == null) return null
331
344
  const emphasized = tick.emphasized === true
345
+ if (!showTickLabel(i, emphasized)) return null
332
346
  const labelColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
333
347
  const labelNode = (
334
348
  <Text
@@ -13,16 +13,8 @@ export {
13
13
  type TempoDisplayProps,
14
14
  type TempoLivePhase,
15
15
  type TempoLiveState,
16
+ type TempoLiveReadout,
16
17
  } from './TempoDisplay'
17
- export {
18
- TempoBar,
19
- type TempoBarProps,
20
- type TempoPhaseKey,
21
- type TempoPacingState,
22
- TEMPO_PACING,
23
- getTempoPacingState,
24
- getTempoFillPct,
25
- } from './TempoBar'
26
18
  export { DeviationBar, type DeviationBarProps } from './DeviationBar'
27
19
  export { IntensityBar, type IntensityBarProps } from './IntensityBar'
28
20
  export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
@@ -0,0 +1,101 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { ScrollView, View } from 'react-native'
3
+ import { SessionRail } from '../../components'
4
+ import { ExerciseHeader, LiveView } from './LiveView'
5
+ import { RestView } from './RestView'
6
+ import {
7
+ dashboardFixture,
8
+ deriveDualModel,
9
+ deriveRailExercises,
10
+ type DashboardModel,
11
+ } from './fixtures'
12
+
13
+ export type LivePageVariant = 'live' | 'live-dual' | 'rest'
14
+
15
+ /** Floor the live panel around phone width so it stops collapsing on a narrow window. */
16
+ const PANEL_MIN_WIDTH = 390
17
+
18
+ export interface LivePageProps {
19
+ /** Which stage to show in the main region. */
20
+ variant?: LivePageVariant
21
+ /** The dashboard store snapshot. Defaults to the built-in fixture. */
22
+ model?: DashboardModel
23
+ }
24
+
25
+ /**
26
+ * Lab specimen — the North Star wall-dashboard CONTENT (mounts inside `DashboardShell`'s
27
+ * children slot): the persistent {@link SessionRail} context beside a swappable stage
28
+ * ({@link LiveView} mid-set / {@link RestView} between sets).
29
+ *
30
+ * `live-dual` renders a dual-mode (bilateral) exercise as TWO stacked {@link LiveView}
31
+ * layers — one per voltra (left/right) — the v1 treatment for two-device sets. A
32
+ * unified split-bar view is a later exploration.
33
+ *
34
+ * NOT a published component — lab-scoped composition of production primitives only.
35
+ * The rail footer pace read-out is intentionally OMITTED (NO-DATA — no store field yet).
36
+ */
37
+ export function LivePage({ variant = 'live', model = dashboardFixture }: LivePageProps) {
38
+ const exercises = deriveRailExercises(model)
39
+ const completedSets = model.session.completedSets.length
40
+ const isLive = variant === 'live' || variant === 'live-dual'
41
+
42
+ return (
43
+ <View className="flex-1 flex-row bg-surface-base">
44
+ <SessionRail
45
+ title={model.session.title}
46
+ exercises={exercises}
47
+ setsDone={isLive ? completedSets + 0.75 : completedSets}
48
+ elapsedMs={18 * 60_000}
49
+ budgetMs={45 * 60_000}
50
+ running={isLive}
51
+ width={272}
52
+ metrics={[
53
+ { label: 'Volume', value: '76%' },
54
+ { label: 'Load', value: '7.3k' },
55
+ { label: 'Fatigue', value: 'MOD' },
56
+ ]}
57
+ />
58
+ {/* footer pace strip — STUB / NO-DATA: intentionally omitted (no store field yet). */}
59
+
60
+ {/* Panel floors at ~phone width so the live view stops collapsing; rail-aware
61
+ breakpoints below this are a later pass. */}
62
+ <View className="flex-1" style={{ minWidth: PANEL_MIN_WIDTH }}>
63
+ {/* workout title + targets — page-level, always visible, independent of single/dual. */}
64
+ <ExerciseHeader session={model.session} />
65
+ <View className="flex-1">
66
+ {variant === 'live-dual' ? (
67
+ <DualLiveStage model={model} />
68
+ ) : variant === 'live' ? (
69
+ // `slot` names the active voltra — the shell has two connected, so the live view
70
+ // flags which one it is reading from (the multi-device single-view case).
71
+ <LiveView model={model} slot="L" />
72
+ ) : (
73
+ <RestView model={model} />
74
+ )}
75
+ </View>
76
+ </View>
77
+ </View>
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Dual-mode stage: two stacked live layers, one per voltra (left dominant, right lags).
83
+ *
84
+ * The stage SCROLLS so two full live read-outs never clip on a height-restricted wall.
85
+ * Each layer also compresses its height-drivers (shorter hero, tighter gaps — see
86
+ * {@link LiveView}'s `side`/`dual` handling) so a tall wall fits both with little or no
87
+ * scroll; a trimmed-per-layer treatment is a later option if scrolling proves awkward.
88
+ */
89
+ function DualLiveStage({ model }: { model: DashboardModel }) {
90
+ const { left, right } = deriveDualModel(model)
91
+ return (
92
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ flexGrow: 1 }}>
93
+ <View style={{ flex: 1, minHeight: 340 }}>
94
+ <LiveView model={left} side="left" />
95
+ </View>
96
+ <View style={{ flex: 1, minHeight: 340 }}>
97
+ <LiveView model={right} side="right" />
98
+ </View>
99
+ </ScrollView>
100
+ )
101
+ }