@titan-design/react-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.d.mts +109 -12
  2. package/dist/index.d.ts +109 -12
  3. package/dist/index.js +648 -231
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +648 -232
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +495 -92
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +496 -93
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
  17. package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
  18. package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
  19. package/src/components/custom/CircularTimer/index.ts +1 -0
  20. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  21. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  22. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  23. package/src/components/custom/Workout/README.md +47 -0
  24. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  25. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  26. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  27. package/src/components/custom/Workout/TempoBar.stories.tsx +69 -0
  28. package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
  29. package/src/components/custom/Workout/TempoBar.tsx +107 -22
  30. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  31. package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
  32. package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
  33. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  34. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  35. package/src/components/custom/Workout/ZoneTrack.tsx +76 -18
  36. package/src/components/custom/index.ts +1 -0
  37. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  38. package/src/components/ui/alert/Alert.test.tsx +52 -0
  39. package/src/components/ui/alert/Alert.tsx +54 -20
  40. package/src/components/ui/progress/Progress.tsx +47 -22
@@ -84,15 +84,24 @@ export interface VelocityStripProps extends ViewProps {
84
84
  * HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
85
85
  * {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface,
86
86
  * padding, per-bar m/s labels, mean/loss info row, interactive collapse); with both
87
- * off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}.
87
+ * off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}. `hero`
88
+ * — the across-the-room, single-set wall treatment: tall bars, a per-bar m/s value
89
+ * label, a dashed running-best reference line, and dashed placeholders for the reps
90
+ * still to come (see {@link targetReps}). Reuses the zone scale and the live-rep pop.
88
91
  */
89
- variant?: 'mini' | 'expanded'
90
- /** `expanded` plot height in px (bars scale to this). Default 60. */
92
+ variant?: 'mini' | 'expanded' | 'hero'
93
+ /** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
91
94
  height?: number
92
95
  /**
93
- * `expanded` bar scaling. `peak` (default) scales to the set's own max (+15%
94
- * headroom). `fixed` scales to a fixed velocity ceiling so bar heights read the
95
- * same absolute velocity across sets (the spotlight).
96
+ * `hero` only: the set's planned rep count. Reps beyond {@link velocities} draw as
97
+ * dashed placeholder stubs so the wall reads "3 of 8 done" at a glance. Ignored
98
+ * (and no placeholders drawn) when absent or ≤ the performed-rep count.
99
+ */
100
+ targetReps?: number
101
+ /**
102
+ * `expanded` / `hero` bar scaling. `peak` (default) scales to the set's own max
103
+ * (+15% headroom). `fixed` scales to a fixed velocity ceiling so bar heights read
104
+ * the same absolute velocity across sets (the spotlight).
96
105
  */
97
106
  scale?: 'peak' | 'fixed'
98
107
  /** `expanded` framed chart: per-bar m/s labels. Default true. */
@@ -372,6 +381,28 @@ const FIXED_MAX_VELOCITY = 1.15
372
381
  /** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
373
382
  const BARE_STUB_HEIGHT = 3
374
383
 
384
+ // --- Hero variant ------------------------------------------------------------
385
+ // The across-the-room, single-set wall treatment. Absorbs the R2 "HeroVelocityBars"
386
+ // candidate into VelocityStrip: tall bars + per-bar value labels + a dashed
387
+ // running-best reference line + dashed placeholders for the reps still to come.
388
+
389
+ /** Default `hero` plot height (px) — tall enough to read a set's velocity shape across a room. */
390
+ const HERO_HEIGHT = 220
391
+ /** Vertical band reserved above the tallest bar for its value label (px). */
392
+ const HERO_LABEL_HEADROOM = 20
393
+ /** Gap between hero bars (px) — a group-notch-free even rhythm at wall scale. */
394
+ const HERO_BAR_GAP = 8
395
+ /** 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
+ /** Top-corner radius on hero bars (px). */
398
+ const HERO_BAR_RADIUS = 5
399
+ /** Minimum drawn height of a performed hero bar (px) — a near-zero rep still reads as a rep. */
400
+ const HERO_MIN_BAR_HEIGHT = 4
401
+ /** Dashed-stub border for a planned-but-unperformed hero rep (charcoal, reads on a dark wall). */
402
+ const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
403
+ /** The dashed running-best reference line + its label (the lightest charcoal step). */
404
+ const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
405
+
375
406
  /** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
376
407
  function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
377
408
  return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * 1.15
@@ -407,6 +438,229 @@ const ANIMATION_DURATION = 400
407
438
  const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
408
439
  const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
409
440
 
441
+ /**
442
+ * The newest-rep entrance shared by the framed `expanded` chart and `hero`: a
443
+ * bounce when the rep is a new set peak, a pop otherwise. Honors reduced motion.
444
+ * Returns the scale `Animated.Value` for the live bar; inert while `active` is
445
+ * false or no live rep is present.
446
+ */
447
+ function useLiveRepPop(
448
+ active: boolean,
449
+ liveRepIndex: number | undefined,
450
+ liveVelocity: number | undefined,
451
+ isNewPeak: boolean
452
+ ): Animated.Value {
453
+ const prefersReducedMotion = usePrefersReducedMotion()
454
+ const [liveScale] = useState(() => new Animated.Value(1))
455
+ useEffect(() => {
456
+ if (!active || liveRepIndex == null || liveVelocity == null) return
457
+ if (prefersReducedMotion) {
458
+ liveScale.setValue(1)
459
+ return
460
+ }
461
+ if (isNewPeak) {
462
+ liveScale.setValue(1)
463
+ Animated.sequence([
464
+ Animated.timing(liveScale, {
465
+ toValue: 1.25,
466
+ duration: 150,
467
+ easing: Easing.out(Easing.quad),
468
+ useNativeDriver: true,
469
+ }),
470
+ Animated.spring(liveScale, {
471
+ toValue: 1,
472
+ friction: 3,
473
+ tension: 140,
474
+ useNativeDriver: true,
475
+ }),
476
+ ]).start()
477
+ } else {
478
+ liveScale.setValue(0.8)
479
+ Animated.timing(liveScale, {
480
+ toValue: 1,
481
+ duration: 300,
482
+ easing: POP_EASING,
483
+ useNativeDriver: true,
484
+ }).start()
485
+ }
486
+ }, [active, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
487
+ return liveScale
488
+ }
489
+
490
+ interface HeroVelocityChartProps {
491
+ /** Performed per-rep mean concentric velocities (m/s). */
492
+ doneVelocities: number[]
493
+ /** Zone → color resolver, shared with the other variants (single-sources the scale). */
494
+ barColorFor: (velocity: number) => string
495
+ /** Bar-height scaling denominator (`peak` set-max or `fixed` ceiling). */
496
+ scaleDenom: number
497
+ /** Running-best velocity (the set peak) — drives the dashed reference line. */
498
+ referenceVelocity: number
499
+ liveRepIndex?: number
500
+ isNewPeak: boolean
501
+ targetReps?: number
502
+ height: number
503
+ className?: string
504
+ viewProps: ViewProps
505
+ }
506
+
507
+ /**
508
+ * The `hero` render: the across-the-room, single-set wall treatment. Tall bars +
509
+ * a per-bar value label + a dashed running-best reference line + dashed placeholders
510
+ * for the reps still to come. Reuses {@link barColorFor} (zone scale) and the
511
+ * {@link useLiveRepPop} entrance so it stays consistent with the framed chart.
512
+ */
513
+ function HeroVelocityChart({
514
+ doneVelocities,
515
+ barColorFor,
516
+ scaleDenom,
517
+ referenceVelocity,
518
+ liveRepIndex,
519
+ isNewPeak,
520
+ targetReps,
521
+ height,
522
+ className,
523
+ viewProps,
524
+ }: HeroVelocityChartProps) {
525
+ const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
526
+ const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
527
+
528
+ // Reserve a band above the tallest bar for its value label so a peak bar never clips.
529
+ const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
530
+ const barHeight = (velocity: number): number =>
531
+ scaleDenom > 0
532
+ ? Math.max(HERO_MIN_BAR_HEIGHT, Math.min(1, velocity / scaleDenom) * plotHeight)
533
+ : HERO_MIN_BAR_HEIGHT
534
+
535
+ const pendingCount = Math.max(0, (targetReps ?? 0) - doneVelocities.length)
536
+ const referencePx =
537
+ referenceVelocity > 0 && scaleDenom > 0
538
+ ? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
539
+ : 0
540
+
541
+ const repCount = doneVelocities.length
542
+ const total = Math.max(repCount, targetReps ?? repCount)
543
+ const label =
544
+ referenceVelocity > 0
545
+ ? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(referenceVelocity)} meters per second`
546
+ : `Velocity chart, ${repCount} of ${total} reps`
547
+
548
+ const { style: externalStyle, ...restProps } = viewProps
549
+
550
+ return (
551
+ <View
552
+ className={className}
553
+ style={[{ height }, externalStyle]}
554
+ accessibilityRole="image"
555
+ accessibilityLabel={label}
556
+ testID="velocity-strip-hero"
557
+ {...restProps}
558
+ >
559
+ <View
560
+ style={{
561
+ flex: 1,
562
+ flexDirection: 'row',
563
+ alignItems: 'flex-end',
564
+ gap: HERO_BAR_GAP,
565
+ position: 'relative',
566
+ borderBottomWidth: 2,
567
+ borderBottomColor: HERO_PENDING_COLOR,
568
+ }}
569
+ >
570
+ {/*
571
+ * Running-best reference line. Deliberately unlabeled: it sits at the tallest
572
+ * bar's top, and that bar already displays its velocity as its own value label,
573
+ * so a "best X.XX" tag would duplicate it — and would collide with that label
574
+ * whenever the peak bar is on the same side (declines peak-left, ends-on-best
575
+ * peak-right). The line alone reads as "how far has velocity fallen from best".
576
+ * The numeric best is in the container's accessibility label.
577
+ */}
578
+ {referencePx > 0 && (
579
+ <View
580
+ accessibilityElementsHidden
581
+ style={{
582
+ position: 'absolute',
583
+ left: 0,
584
+ right: 0,
585
+ bottom: referencePx,
586
+ borderTopWidth: 1,
587
+ borderStyle: 'dashed',
588
+ borderColor: HERO_REFERENCE_COLOR,
589
+ pointerEvents: 'none',
590
+ }}
591
+ testID="velocity-hero-reference"
592
+ />
593
+ )}
594
+
595
+ {doneVelocities.map((velocity, i) => {
596
+ const isLive = liveRepIndex === i
597
+ return (
598
+ <View
599
+ key={i}
600
+ accessibilityElementsHidden
601
+ style={{
602
+ flex: 1,
603
+ maxWidth: HERO_BAR_MAX_WIDTH,
604
+ height: '100%',
605
+ alignItems: 'center',
606
+ justifyContent: 'flex-end',
607
+ }}
608
+ >
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>
616
+ <Animated.View
617
+ style={[
618
+ {
619
+ width: '100%',
620
+ height: barHeight(velocity),
621
+ borderTopLeftRadius: HERO_BAR_RADIUS,
622
+ borderTopRightRadius: HERO_BAR_RADIUS,
623
+ backgroundColor: barColorFor(velocity),
624
+ },
625
+ isLive ? { transform: [{ scale: liveScale }] } : null,
626
+ ]}
627
+ testID={`velocity-bar-${i}`}
628
+ />
629
+ </View>
630
+ )
631
+ })}
632
+
633
+ {Array.from({ length: pendingCount }, (_, i) => (
634
+ <View
635
+ key={`pending-${i}`}
636
+ accessibilityElementsHidden
637
+ style={{
638
+ flex: 1,
639
+ maxWidth: HERO_BAR_MAX_WIDTH,
640
+ height: '100%',
641
+ alignItems: 'center',
642
+ justifyContent: 'flex-end',
643
+ }}
644
+ >
645
+ <View
646
+ style={{
647
+ width: '100%',
648
+ height: HERO_MIN_BAR_HEIGHT * 3,
649
+ borderWidth: 1,
650
+ borderStyle: 'dashed',
651
+ borderColor: HERO_PENDING_COLOR,
652
+ borderTopLeftRadius: HERO_BAR_RADIUS,
653
+ borderTopRightRadius: HERO_BAR_RADIUS,
654
+ }}
655
+ testID="velocity-slot-todo"
656
+ />
657
+ </View>
658
+ ))}
659
+ </View>
660
+ </View>
661
+ )
662
+ }
663
+
410
664
  export function VelocityStrip({
411
665
  velocities,
412
666
  set,
@@ -417,6 +671,7 @@ export function VelocityStrip({
417
671
  onRepPress,
418
672
  variant = 'expanded',
419
673
  height = EXPANDED_HEIGHT,
674
+ targetReps,
420
675
  scale = 'peak',
421
676
  showNumbers = true,
422
677
  showInfo = true,
@@ -456,47 +711,19 @@ export function VelocityStrip({
456
711
  ? (classifyBand(meanVelocity, zones)?.label ?? '')
457
712
  : getVelocityZoneName(meanVelocity)
458
713
 
459
- const prefersReducedMotion = usePrefersReducedMotion()
460
714
  const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
461
715
  const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
462
716
  const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
463
- const [liveScale] = useState(() => new Animated.Value(1))
464
717
 
465
718
  // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
466
719
  const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
467
720
  const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
468
- useEffect(() => {
469
- if (variant !== 'expanded' || !framed || liveRepIndex == null || liveVelocity == null) return
470
- if (prefersReducedMotion) {
471
- liveScale.setValue(1)
472
- return
473
- }
474
- if (isNewPeak) {
475
- liveScale.setValue(1)
476
- Animated.sequence([
477
- Animated.timing(liveScale, {
478
- toValue: 1.25,
479
- duration: 150,
480
- easing: Easing.out(Easing.quad),
481
- useNativeDriver: true,
482
- }),
483
- Animated.spring(liveScale, {
484
- toValue: 1,
485
- friction: 3,
486
- tension: 140,
487
- useNativeDriver: true,
488
- }),
489
- ]).start()
490
- } else {
491
- liveScale.setValue(0.8)
492
- Animated.timing(liveScale, {
493
- toValue: 1,
494
- duration: 300,
495
- easing: POP_EASING,
496
- useNativeDriver: true,
497
- }).start()
498
- }
499
- }, [variant, framed, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
721
+ const liveScale = useLiveRepPop(
722
+ variant === 'expanded' && framed,
723
+ liveRepIndex,
724
+ liveVelocity,
725
+ isNewPeak
726
+ )
500
727
 
501
728
  useEffect(() => {
502
729
  if (variant !== 'expanded' || !framed) return
@@ -529,6 +756,26 @@ export function VelocityStrip({
529
756
  const repCount = doneVelocities.length
530
757
  const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
531
758
 
759
+ if (variant === 'hero') {
760
+ // Hero's plot is far taller than the expanded chart; apply its own default when
761
+ // the caller left `height` at the shared 60px default.
762
+ const heroHeight = height === EXPANDED_HEIGHT ? HERO_HEIGHT : height
763
+ return (
764
+ <HeroVelocityChart
765
+ doneVelocities={doneVelocities}
766
+ barColorFor={barColorFor}
767
+ scaleDenom={scaleDenom}
768
+ referenceVelocity={maxVelocity}
769
+ liveRepIndex={liveRepIndex}
770
+ isNewPeak={isNewPeak}
771
+ targetReps={targetReps}
772
+ height={heroHeight}
773
+ className={className}
774
+ viewProps={props}
775
+ />
776
+ )
777
+ }
778
+
532
779
  if (variant === 'mini') {
533
780
  const { style: externalStyle, ...restProps } = props
534
781
  // The container keeps the uniform REP_GAP (so a no-`set` strip is byte-identical
@@ -102,15 +102,14 @@ export const BareTrack: Story = {
102
102
  args: { zones: FATIGUE_ZONES, max: 40 },
103
103
  }
104
104
 
105
- /** Chunky wall-glanceable density via a taller track. */
105
+ /** The across-the-room wall scale `size="wall"` grows track, needle, tick lines and labels together. */
106
106
  export const WallDensity: Story = {
107
107
  args: {
108
108
  zones: FATIGUE_ZONES,
109
109
  max: 40,
110
110
  marker: { type: 'needle', value: 15 },
111
111
  ticks: FATIGUE_TICKS,
112
- trackHeight: 22,
113
- needleOverhang: 9,
112
+ size: 'wall',
114
113
  },
115
114
  }
116
115
 
@@ -198,3 +198,37 @@ describe('ZoneTrack', () => {
198
198
  })
199
199
  })
200
200
  })
201
+
202
+ describe('ZoneTrack size="wall"', () => {
203
+ const TICKS = [
204
+ { value: 0, label: 'fresh' },
205
+ { value: 40, label: 'stop' },
206
+ ]
207
+
208
+ it('scales the needle and tick labels up together', () => {
209
+ render(
210
+ <ZoneTrack
211
+ zones={ZONES}
212
+ max={40}
213
+ size="wall"
214
+ marker={{ type: 'needle', value: 20 }}
215
+ ticks={TICKS}
216
+ />
217
+ )
218
+ expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '7px' })
219
+ expect(screen.getAllByTestId('zone-track-tick-label')[0]).toHaveStyle({ fontSize: 14 })
220
+ })
221
+
222
+ it('defaults to the compact scale (needle 4 / label 9) when size is omitted', () => {
223
+ render(
224
+ <ZoneTrack zones={ZONES} max={40} marker={{ type: 'needle', value: 20 }} ticks={TICKS} />
225
+ )
226
+ expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '4px' })
227
+ expect(screen.getAllByTestId('zone-track-tick-label')[0]).toHaveStyle({ fontSize: 9 })
228
+ })
229
+
230
+ it('still lets an explicit trackHeight override the size default', () => {
231
+ render(<ZoneTrack zones={ZONES} max={40} size="wall" trackHeight={30} />)
232
+ expect(screen.getByTestId('zone-track-track')).toHaveStyle({ height: '30px' })
233
+ })
234
+ })
@@ -19,9 +19,59 @@ const DEFAULT_MARKER_COLOR = primitiveColors.white
19
19
  /** Tick mark + tick label colour. */
20
20
  const TICK_COLOR = t['text-tertiary']
21
21
 
22
- const DEFAULT_TRACK_HEIGHT = 14
23
- const DEFAULT_NEEDLE_OVERHANG = 6
24
- const NEEDLE_WIDTH = 4
22
+ /** Density. `default` — the compact card/rail gauge. `wall` — the across-the-room dashboard scale. */
23
+ export type ZoneTrackSize = 'default' | 'wall'
24
+
25
+ interface ZoneTrackSizing {
26
+ trackHeight: number
27
+ needleOverhang: number
28
+ needleWidth: number
29
+ tickLineNormal: number
30
+ tickLineEmphasized: number
31
+ tickLineHeightPad: number
32
+ tickLineTopPad: number
33
+ labelRowHeight: number
34
+ labelMarginTop: number
35
+ tickFont: number
36
+ tickLetterSpacing: number
37
+ tickCellWidth: number
38
+ }
39
+
40
+ /**
41
+ * Per-density magnitudes. `default` reproduces the original compact values exactly;
42
+ * `wall` scales the track, needle, tick lines and tick labels up together so the gauge
43
+ * reads across a room. `trackHeight` / `needleOverhang` remain overridable per call.
44
+ */
45
+ const ZONE_SIZES: Record<ZoneTrackSize, ZoneTrackSizing> = {
46
+ default: {
47
+ trackHeight: 14,
48
+ needleOverhang: 6,
49
+ needleWidth: 4,
50
+ tickLineNormal: 1.5,
51
+ tickLineEmphasized: 2,
52
+ tickLineHeightPad: 4,
53
+ tickLineTopPad: 2,
54
+ labelRowHeight: 16,
55
+ labelMarginTop: 5,
56
+ tickFont: 9,
57
+ tickLetterSpacing: 0.5,
58
+ tickCellWidth: 28,
59
+ },
60
+ wall: {
61
+ trackHeight: 24,
62
+ needleOverhang: 9,
63
+ needleWidth: 7,
64
+ tickLineNormal: 2.5,
65
+ tickLineEmphasized: 3.5,
66
+ tickLineHeightPad: 6,
67
+ tickLineTopPad: 3,
68
+ labelRowHeight: 24,
69
+ labelMarginTop: 8,
70
+ tickFont: 14,
71
+ tickLetterSpacing: 0.5,
72
+ tickCellWidth: 48,
73
+ },
74
+ }
25
75
 
26
76
  /** A single colour band of the zone gradient, spanning `[prev.upTo, upTo]` of the domain. */
27
77
  export interface ZoneTrackZone {
@@ -82,9 +132,11 @@ export interface ZoneTrackProps extends ViewProps {
82
132
  ticks?: ZoneTrackTick[]
83
133
  /** Optional translucent range highlight drawn over the gradient. */
84
134
  band?: ZoneTrackBand | null
85
- /** Track height in px. Default 14. */
135
+ /** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). Scales track, needle, ticks and labels together. */
136
+ size?: ZoneTrackSize
137
+ /** Track height in px. Overrides the `size` default (14 default / 24 wall). */
86
138
  trackHeight?: number
87
- /** How far a needle marker overhangs the track top + bottom, in px. Default 6. */
139
+ /** How far a needle marker overhangs the track top + bottom, in px. Overrides the `size` default (6 default / 9 wall). */
88
140
  needleOverhang?: number
89
141
  /** Muted colour of the track behind / beyond the fill. Default a charcoal step. */
90
142
  trackColor?: string
@@ -117,14 +169,18 @@ export function ZoneTrack({
117
169
  marker = null,
118
170
  ticks,
119
171
  band = null,
120
- trackHeight = DEFAULT_TRACK_HEIGHT,
121
- needleOverhang = DEFAULT_NEEDLE_OVERHANG,
172
+ size = 'default',
173
+ trackHeight: trackHeightProp,
174
+ needleOverhang: needleOverhangProp,
122
175
  trackColor = DEFAULT_TRACK_COLOR,
123
176
  className,
124
177
  style,
125
178
  accessibilityLabel,
126
179
  ...props
127
180
  }: ZoneTrackProps) {
181
+ const s = ZONE_SIZES[size]
182
+ const trackHeight = trackHeightProp ?? s.trackHeight
183
+ const needleOverhang = needleOverhangProp ?? s.needleOverhang
128
184
  const isNeedle = marker?.type === 'needle'
129
185
  const markerHeight = isNeedle ? trackHeight + needleOverhang * 2 : trackHeight
130
186
  const markerFrac = marker != null ? fraction(marker.value, min, max) : 0
@@ -231,11 +287,11 @@ export function ZoneTrack({
231
287
  position: 'absolute',
232
288
  top: 0,
233
289
  left: pct(markerFrac),
234
- width: NEEDLE_WIDTH,
290
+ width: s.needleWidth,
235
291
  height: markerHeight,
236
- borderRadius: NEEDLE_WIDTH / 2,
292
+ borderRadius: s.needleWidth / 2,
237
293
  backgroundColor: marker.color ?? DEFAULT_MARKER_COLOR,
238
- transform: [{ translateX: -NEEDLE_WIDTH / 2 }],
294
+ transform: [{ translateX: -s.needleWidth / 2 }],
239
295
  }}
240
296
  />
241
297
  )}
@@ -244,7 +300,7 @@ export function ZoneTrack({
244
300
  {ticks?.map((tick, i) => {
245
301
  const emphasized = tick.emphasized === true
246
302
  const lineColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
247
- const lineWidth = emphasized ? 2 : 1.5
303
+ const lineWidth = emphasized ? s.tickLineEmphasized : s.tickLineNormal
248
304
  return (
249
305
  <View
250
306
  key={`line-${i}`}
@@ -252,10 +308,10 @@ export function ZoneTrack({
252
308
  accessibilityElementsHidden
253
309
  style={{
254
310
  position: 'absolute',
255
- top: (markerHeight - trackHeight) / 2 - 2,
311
+ top: (markerHeight - trackHeight) / 2 - s.tickLineTopPad,
256
312
  left: pct(fraction(tick.value, min, max)),
257
313
  width: lineWidth,
258
- height: trackHeight + 4,
314
+ height: trackHeight + s.tickLineHeightPad,
259
315
  borderRadius: lineWidth / 2,
260
316
  backgroundColor: lineColor,
261
317
  transform: [{ translateX: -lineWidth / 2 }],
@@ -267,7 +323,9 @@ export function ZoneTrack({
267
323
  </View>
268
324
 
269
325
  {ticks != null && ticks.length > 0 && (
270
- <View style={{ position: 'relative', height: 16, marginTop: 5 }}>
326
+ <View
327
+ style={{ position: 'relative', height: s.labelRowHeight, marginTop: s.labelMarginTop }}
328
+ >
271
329
  {ticks.map((tick, i) => {
272
330
  if (tick.label == null) return null
273
331
  const emphasized = tick.emphasized === true
@@ -276,8 +334,8 @@ export function ZoneTrack({
276
334
  <Text
277
335
  testID="zone-track-tick-label"
278
336
  style={{
279
- fontSize: 9,
280
- letterSpacing: 0.5,
337
+ fontSize: s.tickFont,
338
+ letterSpacing: s.tickLetterSpacing,
281
339
  color: labelColor,
282
340
  fontFamily: 'monospace',
283
341
  fontWeight: emphasized ? '700' : '400',
@@ -293,8 +351,8 @@ export function ZoneTrack({
293
351
  style={{
294
352
  position: 'absolute',
295
353
  left: pct(fraction(tick.value, min, max)),
296
- transform: [{ translateX: -14 }],
297
- width: 28,
354
+ transform: [{ translateX: -s.tickCellWidth / 2 }],
355
+ width: s.tickCellWidth,
298
356
  alignItems: 'center',
299
357
  }}
300
358
  >
@@ -6,6 +6,7 @@ export * from './EmptyState'
6
6
  export * from './stepper'
7
7
  export * from './DateTime'
8
8
  export * from './TimerReadout'
9
+ export * from './CircularTimer'
9
10
  export * from './Metric'
10
11
  export * from './Workout'
11
12
  export * from './Treemap'