@titan-design/react-ui 0.7.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 (52) hide show
  1. package/dist/index.d.mts +112 -55
  2. package/dist/index.d.ts +112 -55
  3. package/dist/index.js +795 -501
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +795 -498
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +691 -250
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +692 -251
  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/ExerciseIndicator.tsx +1 -1
  21. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  22. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  23. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  24. package/src/components/custom/Workout/README.md +70 -3
  25. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  26. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  27. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  28. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  29. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  30. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  31. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  32. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  33. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  34. package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
  35. package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
  36. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  37. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  38. package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
  39. package/src/components/custom/Workout/index.ts +1 -9
  40. package/src/components/custom/index.ts +1 -0
  41. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  42. package/src/components/ui/alert/Alert.test.tsx +52 -0
  43. package/src/components/ui/alert/Alert.tsx +54 -20
  44. package/src/components/ui/progress/Progress.tsx +47 -22
  45. package/src/lab/north-star/LivePage.tsx +101 -0
  46. package/src/lab/north-star/LiveView.tsx +409 -0
  47. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  48. package/src/lab/north-star/RestView.tsx +140 -0
  49. package/src/lab/north-star/fixtures.ts +224 -0
  50. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
  51. package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
  52. package/src/components/custom/Workout/TempoBar.tsx +0 -215
@@ -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
@@ -84,15 +85,24 @@ export interface VelocityStripProps extends ViewProps {
84
85
  * HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
85
86
  * {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface,
86
87
  * 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}.
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.
88
92
  */
89
- variant?: 'mini' | 'expanded'
90
- /** `expanded` plot height in px (bars scale to this). Default 60. */
93
+ variant?: 'mini' | 'expanded' | 'hero'
94
+ /** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
91
95
  height?: number
92
96
  /**
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).
97
+ * `hero` only: the set's planned rep count. Reps beyond {@link velocities} draw as
98
+ * dashed placeholder stubs so the wall reads "3 of 8 done" at a glance. Ignored
99
+ * (and no placeholders drawn) when absent or ≤ the performed-rep count.
100
+ */
101
+ targetReps?: number
102
+ /**
103
+ * `expanded` / `hero` bar scaling. `peak` (default) scales to the set's own max
104
+ * (+15% headroom). `fixed` scales to a fixed velocity ceiling so bar heights read
105
+ * the same absolute velocity across sets (the spotlight).
96
106
  */
97
107
  scale?: 'peak' | 'fixed'
98
108
  /** `expanded` framed chart: per-bar m/s labels. Default true. */
@@ -372,9 +382,39 @@ const FIXED_MAX_VELOCITY = 1.15
372
382
  /** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
373
383
  const BARE_STUB_HEIGHT = 3
374
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]
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
+
375
415
  /** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
376
416
  function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
377
- return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * 1.15
417
+ return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * PEAK_HEADROOM
378
418
  }
379
419
 
380
420
  /** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
@@ -407,6 +447,254 @@ const ANIMATION_DURATION = 400
407
447
  const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
408
448
  const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
409
449
 
450
+ /**
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.
455
+ */
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
497
+ }
498
+
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
510
+ targetReps?: number
511
+ height: number
512
+ className?: string
513
+ viewProps: ViewProps
514
+ }
515
+
516
+ /**
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.
521
+ */
522
+ function HeroVelocityChart({
523
+ doneVelocities,
524
+ barColorFor,
525
+ scaleDenom,
526
+ referenceVelocity,
527
+ liveRepIndex,
528
+ isNewPeak,
529
+ targetReps,
530
+ height,
531
+ className,
532
+ 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,
568
+ )
569
+ const showBarLabel = (i: number): boolean =>
570
+ !labelsCrowded || i === peakIndex || i === liveRepIndex
571
+
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`
578
+
579
+ const { style: externalStyle, ...restProps } = viewProps
580
+
581
+ return (
582
+ <View
583
+ className={className}
584
+ style={[{ height }, externalStyle]}
585
+ accessibilityRole="image"
586
+ accessibilityLabel={label}
587
+ testID="velocity-strip-hero"
588
+ {...restProps}
589
+ >
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
+ ))}
693
+ </View>
694
+ </View>
695
+ )
696
+ }
697
+
410
698
  export function VelocityStrip({
411
699
  velocities,
412
700
  set,
@@ -417,6 +705,7 @@ export function VelocityStrip({
417
705
  onRepPress,
418
706
  variant = 'expanded',
419
707
  height = EXPANDED_HEIGHT,
708
+ targetReps,
420
709
  scale = 'peak',
421
710
  showNumbers = true,
422
711
  showInfo = true,
@@ -456,47 +745,19 @@ export function VelocityStrip({
456
745
  ? (classifyBand(meanVelocity, zones)?.label ?? '')
457
746
  : getVelocityZoneName(meanVelocity)
458
747
 
459
- const prefersReducedMotion = usePrefersReducedMotion()
460
748
  const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
461
749
  const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
462
750
  const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
463
- const [liveScale] = useState(() => new Animated.Value(1))
464
751
 
465
752
  // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
466
753
  const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
467
754
  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])
755
+ const liveScale = useLiveRepPop(
756
+ variant === 'expanded' && framed,
757
+ liveRepIndex,
758
+ liveVelocity,
759
+ isNewPeak
760
+ )
500
761
 
501
762
  useEffect(() => {
502
763
  if (variant !== 'expanded' || !framed) return
@@ -529,6 +790,26 @@ export function VelocityStrip({
529
790
  const repCount = doneVelocities.length
530
791
  const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
531
792
 
793
+ 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
797
+ return (
798
+ <HeroVelocityChart
799
+ doneVelocities={doneVelocities}
800
+ barColorFor={barColorFor}
801
+ scaleDenom={scaleDenom}
802
+ referenceVelocity={maxVelocity}
803
+ liveRepIndex={liveRepIndex}
804
+ isNewPeak={isNewPeak}
805
+ targetReps={targetReps}
806
+ height={heroHeight}
807
+ className={className}
808
+ viewProps={props}
809
+ />
810
+ )
811
+ }
812
+
532
813
  if (variant === 'mini') {
533
814
  const { style: externalStyle, ...restProps } = props
534
815
  // 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
+ })
@@ -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'
@@ -19,9 +20,59 @@ const DEFAULT_MARKER_COLOR = primitiveColors.white
19
20
  /** Tick mark + tick label colour. */
20
21
  const TICK_COLOR = t['text-tertiary']
21
22
 
22
- const DEFAULT_TRACK_HEIGHT = 14
23
- const DEFAULT_NEEDLE_OVERHANG = 6
24
- const NEEDLE_WIDTH = 4
23
+ /** Density. `default` — the compact card/rail gauge. `wall` — the across-the-room dashboard scale. */
24
+ export type ZoneTrackSize = 'default' | 'wall'
25
+
26
+ interface ZoneTrackSizing {
27
+ trackHeight: number
28
+ needleOverhang: number
29
+ needleWidth: number
30
+ tickLineNormal: number
31
+ tickLineEmphasized: number
32
+ tickLineHeightPad: number
33
+ tickLineTopPad: number
34
+ labelRowHeight: number
35
+ labelMarginTop: number
36
+ tickFont: number
37
+ tickLetterSpacing: number
38
+ tickCellWidth: number
39
+ }
40
+
41
+ /**
42
+ * Per-density magnitudes. `default` reproduces the original compact values exactly;
43
+ * `wall` scales the track, needle, tick lines and tick labels up together so the gauge
44
+ * reads across a room. `trackHeight` / `needleOverhang` remain overridable per call.
45
+ */
46
+ const ZONE_SIZES: Record<ZoneTrackSize, ZoneTrackSizing> = {
47
+ default: {
48
+ trackHeight: 14,
49
+ needleOverhang: 6,
50
+ needleWidth: 4,
51
+ tickLineNormal: 1.5,
52
+ tickLineEmphasized: 2,
53
+ tickLineHeightPad: 4,
54
+ tickLineTopPad: 2,
55
+ labelRowHeight: 16,
56
+ labelMarginTop: 5,
57
+ tickFont: 9,
58
+ tickLetterSpacing: 0.5,
59
+ tickCellWidth: 28,
60
+ },
61
+ wall: {
62
+ trackHeight: 24,
63
+ needleOverhang: 9,
64
+ needleWidth: 7,
65
+ tickLineNormal: 2.5,
66
+ tickLineEmphasized: 3.5,
67
+ tickLineHeightPad: 6,
68
+ tickLineTopPad: 3,
69
+ labelRowHeight: 24,
70
+ labelMarginTop: 8,
71
+ tickFont: 14,
72
+ tickLetterSpacing: 0.5,
73
+ tickCellWidth: 48,
74
+ },
75
+ }
25
76
 
26
77
  /** A single colour band of the zone gradient, spanning `[prev.upTo, upTo]` of the domain. */
27
78
  export interface ZoneTrackZone {
@@ -82,9 +133,11 @@ export interface ZoneTrackProps extends ViewProps {
82
133
  ticks?: ZoneTrackTick[]
83
134
  /** Optional translucent range highlight drawn over the gradient. */
84
135
  band?: ZoneTrackBand | null
85
- /** Track height in px. Default 14. */
136
+ /** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). Scales track, needle, ticks and labels together. */
137
+ size?: ZoneTrackSize
138
+ /** Track height in px. Overrides the `size` default (14 default / 24 wall). */
86
139
  trackHeight?: number
87
- /** How far a needle marker overhangs the track top + bottom, in px. Default 6. */
140
+ /** How far a needle marker overhangs the track top + bottom, in px. Overrides the `size` default (6 default / 9 wall). */
88
141
  needleOverhang?: number
89
142
  /** Muted colour of the track behind / beyond the fill. Default a charcoal step. */
90
143
  trackColor?: string
@@ -117,14 +170,29 @@ export function ZoneTrack({
117
170
  marker = null,
118
171
  ticks,
119
172
  band = null,
120
- trackHeight = DEFAULT_TRACK_HEIGHT,
121
- needleOverhang = DEFAULT_NEEDLE_OVERHANG,
173
+ size = 'default',
174
+ trackHeight: trackHeightProp,
175
+ needleOverhang: needleOverhangProp,
122
176
  trackColor = DEFAULT_TRACK_COLOR,
123
177
  className,
124
178
  style,
125
179
  accessibilityLabel,
126
180
  ...props
127
181
  }: ZoneTrackProps) {
182
+ const s = ZONE_SIZES[size]
183
+ const trackHeight = trackHeightProp ?? s.trackHeight
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
128
196
  const isNeedle = marker?.type === 'needle'
129
197
  const markerHeight = isNeedle ? trackHeight + needleOverhang * 2 : trackHeight
130
198
  const markerFrac = marker != null ? fraction(marker.value, min, max) : 0
@@ -144,6 +212,7 @@ export function ZoneTrack({
144
212
  accessibilityValue={marker != null ? { min, max, now: marker.value } : { min, max, now: min }}
145
213
  accessibilityLabel={accessibilityLabel ?? `Zone track: ${marker?.value ?? min}`}
146
214
  testID="zone-track"
215
+ onLayout={onTrackLayout}
147
216
  {...props}
148
217
  >
149
218
  <View style={{ position: 'relative', height: markerHeight }}>
@@ -231,11 +300,11 @@ export function ZoneTrack({
231
300
  position: 'absolute',
232
301
  top: 0,
233
302
  left: pct(markerFrac),
234
- width: NEEDLE_WIDTH,
303
+ width: s.needleWidth,
235
304
  height: markerHeight,
236
- borderRadius: NEEDLE_WIDTH / 2,
305
+ borderRadius: s.needleWidth / 2,
237
306
  backgroundColor: marker.color ?? DEFAULT_MARKER_COLOR,
238
- transform: [{ translateX: -NEEDLE_WIDTH / 2 }],
307
+ transform: [{ translateX: -s.needleWidth / 2 }],
239
308
  }}
240
309
  />
241
310
  )}
@@ -244,7 +313,7 @@ export function ZoneTrack({
244
313
  {ticks?.map((tick, i) => {
245
314
  const emphasized = tick.emphasized === true
246
315
  const lineColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
247
- const lineWidth = emphasized ? 2 : 1.5
316
+ const lineWidth = emphasized ? s.tickLineEmphasized : s.tickLineNormal
248
317
  return (
249
318
  <View
250
319
  key={`line-${i}`}
@@ -252,10 +321,10 @@ export function ZoneTrack({
252
321
  accessibilityElementsHidden
253
322
  style={{
254
323
  position: 'absolute',
255
- top: (markerHeight - trackHeight) / 2 - 2,
324
+ top: (markerHeight - trackHeight) / 2 - s.tickLineTopPad,
256
325
  left: pct(fraction(tick.value, min, max)),
257
326
  width: lineWidth,
258
- height: trackHeight + 4,
327
+ height: trackHeight + s.tickLineHeightPad,
259
328
  borderRadius: lineWidth / 2,
260
329
  backgroundColor: lineColor,
261
330
  transform: [{ translateX: -lineWidth / 2 }],
@@ -267,17 +336,20 @@ export function ZoneTrack({
267
336
  </View>
268
337
 
269
338
  {ticks != null && ticks.length > 0 && (
270
- <View style={{ position: 'relative', height: 16, marginTop: 5 }}>
339
+ <View
340
+ style={{ position: 'relative', height: s.labelRowHeight, marginTop: s.labelMarginTop }}
341
+ >
271
342
  {ticks.map((tick, i) => {
272
343
  if (tick.label == null) return null
273
344
  const emphasized = tick.emphasized === true
345
+ if (!showTickLabel(i, emphasized)) return null
274
346
  const labelColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
275
347
  const labelNode = (
276
348
  <Text
277
349
  testID="zone-track-tick-label"
278
350
  style={{
279
- fontSize: 9,
280
- letterSpacing: 0.5,
351
+ fontSize: s.tickFont,
352
+ letterSpacing: s.tickLetterSpacing,
281
353
  color: labelColor,
282
354
  fontFamily: 'monospace',
283
355
  fontWeight: emphasized ? '700' : '400',
@@ -293,8 +365,8 @@ export function ZoneTrack({
293
365
  style={{
294
366
  position: 'absolute',
295
367
  left: pct(fraction(tick.value, min, max)),
296
- transform: [{ translateX: -14 }],
297
- width: 28,
368
+ transform: [{ translateX: -s.tickCellWidth / 2 }],
369
+ width: s.tickCellWidth,
298
370
  alignItems: 'center',
299
371
  }}
300
372
  >