@titan-design/react-ui 0.13.0 → 0.14.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 (146) hide show
  1. package/README.md +14 -0
  2. package/dist/bodymap.js +129 -76
  3. package/dist/bodymap.js.map +1 -1
  4. package/dist/bodymap.mjs +129 -76
  5. package/dist/bodymap.mjs.map +1 -1
  6. package/dist/index.d.mts +117 -26
  7. package/dist/index.d.ts +117 -26
  8. package/dist/index.js +677 -450
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +673 -451
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/{pages-_3Ajpiqu.d.ts → pages-CsjsUn0G.d.ts} +30 -13
  13. package/dist/{pages-CpW3OB3g.d.mts → pages-ZbT_pD_U.d.mts} +30 -13
  14. package/dist/pages.d.mts +1 -1
  15. package/dist/pages.d.ts +1 -1
  16. package/dist/pages.js +676 -701
  17. package/dist/pages.js.map +1 -1
  18. package/dist/pages.mjs +676 -701
  19. package/dist/pages.mjs.map +1 -1
  20. package/dist/theme/index.d.mts +31 -7
  21. package/dist/theme/index.d.ts +31 -7
  22. package/dist/theme/index.js +84 -20
  23. package/dist/theme/index.js.map +1 -1
  24. package/dist/theme/index.mjs +83 -21
  25. package/dist/theme/index.mjs.map +1 -1
  26. package/dist/theme/tokens-css.js +84 -20
  27. package/dist/theme/tokens-css.js.map +1 -1
  28. package/dist/theme/tokens-css.mjs +84 -20
  29. package/dist/theme/tokens-css.mjs.map +1 -1
  30. package/dist/theme/tokens.d.mts +56 -7
  31. package/dist/theme/tokens.d.ts +56 -7
  32. package/dist/theme/tokens.js +72 -20
  33. package/dist/theme/tokens.js.map +1 -1
  34. package/dist/theme/tokens.mjs +71 -21
  35. package/dist/theme/tokens.mjs.map +1 -1
  36. package/docs/render-testing-pattern.md +130 -0
  37. package/package.json +1 -1
  38. package/src/arch/Architecture.stories.tsx +3 -1
  39. package/src/arch/arch-graph.json +74 -72
  40. package/src/components/custom/ActiveWork/InitiativeBrief.tsx +2 -1
  41. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +8 -3
  42. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  43. package/src/components/custom/Gauge/Gauge.stories.tsx +4 -1
  44. package/src/components/custom/Gauge/Gauge.tsx +3 -1
  45. package/src/components/custom/Scatter/Scatter.stories.tsx +10 -7
  46. package/src/components/custom/Scatter/Scatter.tsx +6 -4
  47. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  48. package/src/components/custom/Table/Table.stories.tsx +1 -1
  49. package/src/components/custom/Table/Table.tsx +2 -2
  50. package/src/components/custom/Treemap/Treemap.stories.tsx +13 -10
  51. package/src/components/custom/Treemap/Treemap.tsx +4 -1
  52. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +2 -1
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +11 -7
  54. package/src/components/custom/Workout/BaseBadge.tsx +3 -2
  55. package/src/components/custom/Workout/BodyMap.tsx +5 -3
  56. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +13 -3
  57. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +5 -3
  58. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +2 -1
  59. package/src/components/custom/Workout/CapacityBandChart.tsx +5 -3
  60. package/src/components/custom/Workout/DeviationBar.test.tsx +5 -2
  61. package/src/components/custom/Workout/DeviationBar.tsx +5 -2
  62. package/src/components/custom/Workout/DualVelocityStrip.stories.tsx +7 -6
  63. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +29 -16
  64. package/src/components/custom/Workout/ExerciseCard.stories.tsx +8 -8
  65. package/src/components/custom/Workout/ExerciseCard.test.tsx +39 -19
  66. package/src/components/custom/Workout/ExerciseCard.tsx +92 -167
  67. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +89 -6
  68. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +189 -1
  69. package/src/components/custom/Workout/ExerciseCardHeading.tsx +167 -18
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +3 -2
  71. package/src/components/custom/Workout/ExerciseHeading.tsx +192 -47
  72. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +6 -5
  73. package/src/components/custom/Workout/InputBar.tsx +4 -2
  74. package/src/components/custom/Workout/IntensityBar.tsx +4 -1
  75. package/src/components/custom/Workout/MesoCard.tsx +2 -1
  76. package/src/components/custom/Workout/MesoStatusCard.tsx +11 -10
  77. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +8 -3
  78. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +12 -2
  79. package/src/components/custom/Workout/README.md +58 -12
  80. package/src/components/custom/Workout/ReadinessCheck.tsx +4 -2
  81. package/src/components/custom/Workout/RestTimer.stories.tsx +13 -2
  82. package/src/components/custom/Workout/RestTimer.tsx +4 -2
  83. package/src/components/custom/Workout/SegmentedBar.stories.tsx +16 -12
  84. package/src/components/custom/Workout/SegmentedBar.tsx +4 -3
  85. package/src/components/custom/Workout/SetStrip.stories.tsx +3 -2
  86. package/src/components/custom/Workout/SetsRepsLoad.tsx +23 -9
  87. package/src/components/custom/Workout/Sparkline.stories.tsx +13 -10
  88. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +2 -1
  89. package/src/components/custom/Workout/StrengthTrendChart.tsx +8 -6
  90. package/src/components/custom/Workout/TempoDisplay.stories.tsx +4 -3
  91. package/src/components/custom/Workout/TempoDisplay.tsx +2 -0
  92. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +3 -2
  93. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +1 -1
  94. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +40 -0
  95. package/src/components/custom/Workout/VelocityStrip.test.tsx +48 -6
  96. package/src/components/custom/Workout/VelocityStrip.tsx +23 -14
  97. package/src/components/custom/Workout/ZoneTrack.stories.tsx +3 -1
  98. package/src/components/custom/Workout/exerciseRowState.ts +29 -0
  99. package/src/components/custom/Workout/index.ts +12 -2
  100. package/src/components/custom/Workout/metricText.stories.tsx +9 -8
  101. package/src/components/custom/Workout/metricText.tsx +4 -2
  102. package/src/components/custom/Workout/setHeadingKit.tsx +12 -10
  103. package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
  104. package/src/components/custom/charts/README.md +5 -0
  105. package/src/components/custom/charts/SetBarChart.test.tsx +16 -0
  106. package/src/components/custom/charts/SetBarChart.tsx +14 -5
  107. package/src/components/custom/charts/flatBarGeometry.test.ts +41 -0
  108. package/src/components/custom/charts/flatBarGeometry.ts +32 -0
  109. package/src/components/custom/stepper/Stepper.tsx +8 -6
  110. package/src/components/ui/alert/Alert.stories.tsx +14 -4
  111. package/src/components/ui/alert/Alert.tsx +24 -3
  112. package/src/components/ui/button/Button.tsx +12 -12
  113. package/src/components/ui/checkbox/Checkbox.tsx +2 -2
  114. package/src/components/ui/data-row/DataRow.stories.tsx +6 -2
  115. package/src/components/ui/drawer/Drawer.tsx +4 -1
  116. package/src/components/ui/icon-box/IconBox.tsx +8 -6
  117. package/src/components/ui/indicator/Indicator.stories.tsx +4 -3
  118. package/src/components/ui/list-item/ListItem.stories.tsx +7 -4
  119. package/src/components/ui/modal/Modal.tsx +2 -0
  120. package/src/components/ui/pill/Pill.stories.tsx +53 -0
  121. package/src/components/ui/pill/Pill.tsx +10 -6
  122. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  123. package/src/components/ui/progress/Progress.tsx +9 -6
  124. package/src/components/ui/radio/Radio.tsx +1 -1
  125. package/src/components/ui/section/Section.stories.tsx +10 -10
  126. package/src/components/ui/select/Select.tsx +2 -1
  127. package/src/components/ui/spinner/Spinner.tsx +3 -1
  128. package/src/components/ui/stack/Stack.stories.tsx +7 -7
  129. package/src/components/ui/surface/Surface.stories.tsx +75 -28
  130. package/src/components/ui/switch/Switch.tsx +1 -1
  131. package/src/components/ui/tabs/Tabs.tsx +4 -2
  132. package/src/components/ui/toast/Toast.tsx +6 -4
  133. package/src/components/ui/toolbar-button/ToolbarButton.tsx +13 -6
  134. package/src/stories/PresetShowcase.stories.tsx +1 -1
  135. package/src/test/no-raw-color.test.ts +39 -0
  136. package/src/theme/Alpha.stories.tsx +103 -0
  137. package/src/theme/ProposedTokensVW82.stories.tsx +414 -0
  138. package/src/theme/config.ts +12 -0
  139. package/src/theme/global.css +15 -3
  140. package/src/theme/subtle-tone-weight.test.ts +163 -0
  141. package/src/theme/tailwind-var-opacity.test.ts +163 -0
  142. package/src/theme/tokens/primitives.ts +33 -0
  143. package/src/theme/tokens/semantic-hex-guard.test.ts +322 -0
  144. package/src/theme/tokens/semantic.ts +56 -20
  145. package/src/utils/colors.alpha-adoption.test.ts +408 -0
  146. package/tailwind.config.js +50 -9
@@ -1,6 +1,10 @@
1
1
  import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View, Text } from 'react-native'
3
3
  import { RestTimer } from './RestTimer'
4
+ import { greyRamp } from '../../../theme/tokens/primitives'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+
7
+ const t = getSemanticColors('dark')
4
8
 
5
9
  const meta: Meta<typeof RestTimer> = {
6
10
  title: 'Custom/Workout/RestTimer',
@@ -100,10 +104,17 @@ export const JustStarted: Story = {
100
104
 
101
105
  /** Wall-background frame for the ring stories (mirrors the north-star rest page). */
102
106
  const ringDecorator: Decorator = (Story) => (
103
- <View style={{ width: 420, padding: 32, alignItems: 'center', backgroundColor: '#0E0E0E' }}>
107
+ <View
108
+ style={{
109
+ width: 420,
110
+ padding: 32,
111
+ alignItems: 'center',
112
+ backgroundColor: t['background-frame'],
113
+ }}
114
+ >
104
115
  <Text
105
116
  style={{
106
- color: '#5A5A5A',
117
+ color: greyRamp[700],
107
118
  fontSize: 10,
108
119
  fontWeight: '700',
109
120
  letterSpacing: 1,
@@ -2,6 +2,8 @@
2
2
  import { View, Text, Pressable } from 'react-native'
3
3
  import { resolveColor } from '../../../theme/resolve-color'
4
4
  import { getSemanticColors } from '../../../theme/tokens/semantic'
5
+ import { primitiveColors } from '../../../theme/tokens/primitives'
6
+ import { alpha } from '../../../utils/colors'
5
7
  import { useTimer } from '../../../hooks/useTimer'
6
8
  import { CircularTimer } from '../CircularTimer/CircularTimer'
7
9
 
@@ -37,7 +39,7 @@ function RestActions({ onAddTime, onSkip }: { onAddTime: () => void; onSkip: ()
37
39
  <Pressable
38
40
  onPress={onAddTime}
39
41
  style={{
40
- backgroundColor: 'rgba(255,255,255,0.06)',
42
+ backgroundColor: alpha(primitiveColors.white, 0.06),
41
43
  paddingVertical: 8,
42
44
  paddingHorizontal: 20,
43
45
  borderRadius: 8,
@@ -56,7 +58,7 @@ function RestActions({ onAddTime, onSkip }: { onAddTime: () => void; onSkip: ()
56
58
  <Pressable
57
59
  onPress={onSkip}
58
60
  style={{
59
- backgroundColor: 'rgba(255,121,0,0.12)',
61
+ backgroundColor: alpha(BRAND_PRIMARY, 0.12),
60
62
  paddingVertical: 8,
61
63
  paddingHorizontal: 20,
62
64
  borderRadius: 8,
@@ -1,6 +1,10 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { SegmentedBar } from './SegmentedBar'
3
3
  import { Surface } from '../../ui/surface'
4
+ import { primitiveColors, primitiveRamps } from '../../../theme/tokens/primitives'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+
7
+ const t = getSemanticColors('dark')
4
8
 
5
9
  /**
6
10
  * `SegmentedBar` — a horizontal track split into weighted, individually-fillable
@@ -44,10 +48,10 @@ export const EqualSegments: Story = {
44
48
  args: {
45
49
  height: 8,
46
50
  segments: [
47
- { color: '#D14343' },
48
- { color: '#FF7900' },
49
- { color: '#F9B415' },
50
- { color: '#2ED573' },
51
+ { color: primitiveRamps.red[600] },
52
+ { color: t['brand-primary'] },
53
+ { color: t['status-warning'] },
54
+ { color: t['status-success'] },
51
55
  ],
52
56
  },
53
57
  }
@@ -58,9 +62,9 @@ export const Weighted: Story = {
58
62
  gap: 4,
59
63
  radius: 2,
60
64
  segments: [
61
- { color: '#2ED573', weight: 3 },
62
- { color: '#F9B415', weight: 2 },
63
- { color: '#D14343', weight: 1 },
65
+ { color: t['status-success'], weight: 3 },
66
+ { color: t['status-warning'], weight: 2 },
67
+ { color: primitiveRamps.red[600], weight: 1 },
64
68
  ],
65
69
  },
66
70
  parameters: {
@@ -74,9 +78,9 @@ export const PartialFills: Story = {
74
78
  gap: 4,
75
79
  radius: 2,
76
80
  segments: [
77
- { color: '#2ED573', fill: 1 },
78
- { color: '#F9B415', fill: 0.6 },
79
- { color: '#01B5D1', fill: 0.3, pulse: true },
81
+ { color: t['status-success'], fill: 1 },
82
+ { color: t['status-warning'], fill: 0.6 },
83
+ { color: primitiveRamps.cyan[400], fill: 0.3, pulse: true },
80
84
  ],
81
85
  },
82
86
  parameters: {
@@ -90,8 +94,8 @@ export const WithMarker: Story = {
90
94
  args: {
91
95
  height: 10,
92
96
  radius: 2,
93
- segments: [{ color: '#01B5D1', fill: 0.7 }],
94
- marker: { position: 0.5, color: '#FFFFFF' },
97
+ segments: [{ color: primitiveRamps.cyan[400], fill: 0.7 }],
98
+ marker: { position: 0.5, color: primitiveColors.white },
95
99
  },
96
100
  parameters: {
97
101
  docs: { description: { story: 'A single fill with a target marker at the halfway point.' } },
@@ -1,6 +1,7 @@
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
3
  import { View, Animated, Easing, type ViewProps, type DimensionValue } from 'react-native'
4
+ import { SET_LEVEL_FLAT_BAR } from '../charts/flatBarGeometry'
4
5
 
5
6
  /** ~1.9s full cycle: a pulsing segment eases up then back down (2 × half). */
6
7
  const PULSE_HALF_MS = 950
@@ -9,7 +10,7 @@ const PULSE_HALF_MS = 950
9
10
  const PULSE_MIN_OPACITY = 0.45
10
11
 
11
12
  /** Default between-segment gap — matches SetStrip's between-set gap so strips read the same. */
12
- const SEGMENTED_BAR_GAP = 5
13
+ const SEGMENTED_BAR_GAP = SET_LEVEL_FLAT_BAR.gap
13
14
 
14
15
  export interface SegmentedBarSegment {
15
16
  /** Flex weight of this segment's slot. Default 1 (all segments equal width). */
@@ -98,9 +99,9 @@ function usePulse(active: boolean): Animated.Value {
98
99
  */
99
100
  export function SegmentedBar({
100
101
  segments,
101
- height = 8,
102
+ height = SET_LEVEL_FLAT_BAR.height,
102
103
  gap = SEGMENTED_BAR_GAP,
103
- radius = 2,
104
+ radius = SET_LEVEL_FLAT_BAR.radius,
104
105
  marker = null,
105
106
  segmentTestID,
106
107
  style,
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View, Text } from 'react-native'
3
3
  import { SetStrip, type SetStripSet } from './SetStrip'
4
4
  import { Surface } from '../../ui/surface'
5
+ import { greyRamp } from '../../../theme/tokens/primitives'
5
6
 
6
7
  /**
7
8
  * `SetStrip` — the per-set segmented performance strip. One continuous bar per
@@ -138,8 +139,8 @@ function SheetRow({ label, meta, sets }: { label: string; meta: string; sets: Se
138
139
  return (
139
140
  <View style={{ gap: 6 }}>
140
141
  <View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
141
- <Text style={{ color: '#F3F4F6', fontSize: 13, fontWeight: '800' }}>{label}</Text>
142
- <Text style={{ color: '#6B7280', fontSize: 10, fontFamily: 'monospace' }}>{meta}</Text>
142
+ <Text style={{ color: greyRamp[50], fontSize: 13, fontWeight: '800' }}>{label}</Text>
143
+ <Text style={{ color: greyRamp[600], fontSize: 10, fontFamily: 'monospace' }}>{meta}</Text>
143
144
  </View>
144
145
  <SetStrip sets={sets} />
145
146
  </View>
@@ -15,12 +15,18 @@ export interface SetsRepsLoadProps extends ViewProps {
15
15
  unit?: string
16
16
  /** Cell font size (px). Default 11 — the compact rail/card scale. Raise for a wall read-out. */
17
17
  fontSize?: number
18
+ /**
19
+ * Flatten the whole run to one dimmer, regular-weight tone (the collapsed/upcoming
20
+ * card row) instead of the bright bold value / muted separator split (the rail).
21
+ * Also drops the `×` separator's padding spaces — "3×6", not "3 × 6".
22
+ */
23
+ muted?: boolean
18
24
  className?: string
19
25
  }
20
26
 
21
27
  /**
22
28
  * The `sets × reps @ load` prescription line, in the TempoDisplay visual language
23
- * (Inter · 600 · letter-spacing 1 · value cells with muted `×` / `@` separators).
29
+ * (Inter · letter-spacing 1 · value cells with muted `×` / `@` separators).
24
30
  * Shares the {@link MetricCell} primitive with TempoDisplay so the two stay in step.
25
31
  */
26
32
  export function SetsRepsLoad({
@@ -29,11 +35,17 @@ export function SetsRepsLoad({
29
35
  load,
30
36
  unit = 'lb',
31
37
  fontSize,
38
+ muted = false,
32
39
  className,
33
40
  ...props
34
41
  }: SetsRepsLoadProps) {
35
- const value = useOnSurfaceColor('primary')
36
- const sep = useOnSurfaceColor('tertiary')
42
+ const primary = useOnSurfaceColor('primary')
43
+ const tertiary = useOnSurfaceColor('tertiary')
44
+ const secondary = useOnSurfaceColor('secondary')
45
+ const value = muted ? secondary : primary
46
+ const sep = muted ? secondary : tertiary
47
+ const weight = muted ? 400 : 600
48
+ const times = muted ? TIMES : ` ${TIMES} `
37
49
 
38
50
  return (
39
51
  <View
@@ -43,18 +55,20 @@ export function SetsRepsLoad({
43
55
  testID="sets-reps-load"
44
56
  {...props}
45
57
  >
46
- <MetricCell color={value} fontSize={fontSize}>
58
+ <MetricCell color={value} fontSize={fontSize} weight={weight}>
47
59
  {sets}
48
60
  </MetricCell>
49
- <MetricCell color={sep} fontSize={fontSize}>{` ${TIMES} `}</MetricCell>
50
- <MetricCell color={value} fontSize={fontSize}>
61
+ <MetricCell color={sep} fontSize={fontSize} weight={weight}>
62
+ {times}
63
+ </MetricCell>
64
+ <MetricCell color={value} fontSize={fontSize} weight={weight}>
51
65
  {reps}
52
66
  </MetricCell>
53
- <MetricCell color={sep} fontSize={fontSize}>{` ${AT} `}</MetricCell>
54
- <MetricCell color={value} fontSize={fontSize}>
67
+ <MetricCell color={sep} fontSize={fontSize} weight={weight}>{` ${AT} `}</MetricCell>
68
+ <MetricCell color={value} fontSize={fontSize} weight={weight}>
55
69
  {load}
56
70
  </MetricCell>
57
- <MetricCell color={sep} fontSize={fontSize}>{` ${unit}`}</MetricCell>
71
+ <MetricCell color={sep} fontSize={fontSize} weight={weight}>{` ${unit}`}</MetricCell>
58
72
  </View>
59
73
  )
60
74
  }
@@ -1,6 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View } from 'react-native'
3
3
  import { Sparkline } from './Sparkline'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
+
6
+ const t = getSemanticColors('dark')
4
7
 
5
8
  const meta: Meta<typeof Sparkline> = {
6
9
  title: 'Custom/Workout/DataViz/Sparkline',
@@ -56,7 +59,7 @@ export const Descending: Story = {
56
59
  args: {
57
60
  data: descending,
58
61
  showDots: true,
59
- color: '#ff4757',
62
+ color: t['status-error-vivid'],
60
63
  },
61
64
  }
62
65
 
@@ -64,7 +67,7 @@ export const UCurve: Story = {
64
67
  args: {
65
68
  data: uCurve,
66
69
  showDots: true,
67
- color: '#2ed573',
70
+ color: t['status-success'],
68
71
  },
69
72
  }
70
73
 
@@ -72,7 +75,7 @@ export const Flat: Story = {
72
75
  args: {
73
76
  data: flat,
74
77
  showDots: true,
75
- color: '#ffd43b',
78
+ color: t['status-warning-light'],
76
79
  },
77
80
  }
78
81
 
@@ -81,8 +84,8 @@ export const WithReferenceLines: Story = {
81
84
  data: ascending,
82
85
  showDots: true,
83
86
  referenceLines: [
84
- { value: 30, color: '#ff4757', dashed: true },
85
- { value: 45, color: '#2ed573', dashed: false },
87
+ { value: 30, color: t['status-error-vivid'], dashed: true },
88
+ { value: 45, color: t['status-success'], dashed: false },
86
89
  ],
87
90
  },
88
91
  }
@@ -99,7 +102,7 @@ export const DotsAndHighlightLast: Story = {
99
102
  data: volatile,
100
103
  showDots: true,
101
104
  highlightLast: true,
102
- color: '#ffa502',
105
+ color: t['status-warning'],
103
106
  },
104
107
  }
105
108
 
@@ -116,10 +119,10 @@ export const AllShapes: Story = {
116
119
  render: () => (
117
120
  <View style={{ gap: 16, padding: 16 }}>
118
121
  <Sparkline data={ascending} showDots highlightLast />
119
- <Sparkline data={descending} showDots color="#ff4757" />
120
- <Sparkline data={uCurve} showDots color="#2ed573" />
121
- <Sparkline data={flat} showDots color="#ffd43b" />
122
- <Sparkline data={volatile} showDots color="#ffa502" highlightLast />
122
+ <Sparkline data={descending} showDots color={t['status-error-vivid']} />
123
+ <Sparkline data={uCurve} showDots color={t['status-success']} />
124
+ <Sparkline data={flat} showDots color={t['status-warning-light']} />
125
+ <Sparkline data={volatile} showDots color={t['status-warning']} highlightLast />
123
126
  </View>
124
127
  ),
125
128
  }
@@ -3,6 +3,7 @@ import { useState } from 'react'
3
3
  import { View, Text } from 'react-native'
4
4
  import { StrengthTrendChart } from './StrengthTrendChart'
5
5
  import type { StrengthTrendDataPoint } from './StrengthTrendChart'
6
+ import { greyRamp } from '../../../theme/tokens/primitives'
6
7
 
7
8
  const actual: StrengthTrendDataPoint[] = [
8
9
  { date: '2026-01-06', e1rm: 215, sessionLabel: 'Jan 6' },
@@ -122,7 +123,7 @@ function InteractiveChart() {
122
123
  unit="lbs"
123
124
  onPointPress={setSelected}
124
125
  />
125
- <Text style={{ marginTop: 12, fontSize: 12, color: '#9CA3AF' }}>
126
+ <Text style={{ marginTop: 12, fontSize: 12, color: greyRamp[400] }}>
126
127
  {selected
127
128
  ? `Tapped ${selected.sessionLabel ?? selected.date}: ${selected.e1rm} lbs`
128
129
  : 'Tap a point on the chart.'}
@@ -4,6 +4,8 @@ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-n
4
4
  import { cn } from '../../../utils/cn'
5
5
  import { resolveColor } from '../../../theme/resolve-color'
6
6
  import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+ import { primitiveColors } from '../../../theme/tokens/primitives'
8
+ import { alpha } from '../../../utils/colors'
7
9
 
8
10
  const sem = getSemanticColors('dark')
9
11
 
@@ -14,11 +16,11 @@ const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
14
16
  const STATUS_SUCCESS = sem['status-success']
15
17
  const STATUS_ERROR = sem['status-error']
16
18
  const STATUS_WARNING = sem['status-warning']
17
- const GRID_LINE = 'rgba(255,255,255,0.06)'
18
- const SUCCESS_PILL_BG = 'rgba(46,213,115,0.10)'
19
- const SUCCESS_PILL_BORDER = 'rgba(46,213,115,0.20)'
20
- const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
21
- const ERROR_PILL_BORDER = 'rgba(209,67,67,0.20)'
19
+ const GRID_LINE = alpha(primitiveColors.white, 0.06)
20
+ const SUCCESS_PILL_BG = alpha(STATUS_SUCCESS, 0.1)
21
+ const SUCCESS_PILL_BORDER = alpha(STATUS_SUCCESS, 0.2)
22
+ const ERROR_PILL_BG = alpha(STATUS_ERROR, 0.1)
23
+ const ERROR_PILL_BORDER = alpha(STATUS_ERROR, 0.2)
22
24
 
23
25
  /** Left gutter reserved for y-axis value labels. */
24
26
  const PLOT_LEFT = 26
@@ -379,7 +381,7 @@ export function StrengthTrendChart({
379
381
  height,
380
382
  borderLeftWidth: 1,
381
383
  borderStyle: 'dashed',
382
- borderLeftColor: 'rgba(255,255,255,0.10)',
384
+ borderLeftColor: alpha(primitiveColors.white, 0.1),
383
385
  }}
384
386
  />
385
387
  ))}
@@ -3,6 +3,7 @@ import type { ComponentProps } from 'react'
3
3
  import { useEffect, useState } from 'react'
4
4
  import { View, Text } from 'react-native'
5
5
  import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
6
+ import { greyRamp } from '../../../theme/tokens/primitives'
6
7
 
7
8
  // tempo = [eccentric, pauseBottom, concentric, pauseTop]
8
9
  const meta: Meta<typeof TempoDisplay> = {
@@ -166,7 +167,7 @@ function Condition({
166
167
  }: { caption: string } & ComponentProps<typeof TempoDisplay>) {
167
168
  return (
168
169
  <View style={{ gap: 5 }}>
169
- <Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
170
+ <Text style={{ color: greyRamp[400], fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
170
171
  {caption}
171
172
  </Text>
172
173
  <TempoDisplay showLabel={false} showInfo={false} fontSize={26} {...props} />
@@ -179,7 +180,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode
179
180
  <View style={{ gap: 12 }}>
180
181
  <Text
181
182
  style={{
182
- color: '#E5E7EB',
183
+ color: greyRamp[100],
183
184
  fontFamily: 'Inter, sans-serif',
184
185
  fontSize: 12,
185
186
  fontWeight: '700',
@@ -313,7 +314,7 @@ export const ActiveTempoConditions: Story = {
313
314
  live={{ activePhase: null, phaseElapsedMs: 0 }}
314
315
  />
315
316
  <View style={{ gap: 5 }}>
316
- <Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
317
+ <Text style={{ color: greyRamp[400], fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
317
318
  Animated · Ecc → Pause → Con → 2s rest, banking each phase
318
319
  </Text>
319
320
  <LiveTempoDemo />
@@ -53,6 +53,8 @@ export interface TempoDisplayProps extends ViewProps {
53
53
  }
54
54
 
55
55
  const INTER = 'Inter, sans-serif'
56
+ // VW-82: Tailwind gray-500, not titan's text-tertiary (#888684) despite the name.
57
+ // Left raw — re-pointing it at the ramp is a visual change awaiting sign-off.
56
58
  const TEXT_TERTIARY = '#6B7280'
57
59
  const STATUS_ERROR = t['status-error'] // slow — over the target time
58
60
  const STATUS_SUCCESS = t['status-success'] // on target (within the band of 0.0)
@@ -57,9 +57,10 @@ const meta: Meta<typeof DualVelocityStrip> = {
57
57
  },
58
58
  variant: {
59
59
  control: 'inline-radio',
60
- options: ['hero', 'compact', 'rail'],
60
+ options: ['hero', 'compact', 'dual-expanded'],
61
61
  description:
62
- 'hero (wall: labels + reference lines), compact (flat resting fold), or rail (lean, neither)',
62
+ 'hero (wall: labels + reference lines), compact (flat resting fold), or dual-expanded ' +
63
+ '(lean, neither — `"rail"` is a deprecated alias for the same renderer)',
63
64
  },
64
65
  scale: {
65
66
  control: 'inline-radio',
@@ -199,7 +199,7 @@ export const Responsive: Story = {
199
199
  <DualVelocityStrip
200
200
  left={{ velocities: REP_SET, label: 'Left' }}
201
201
  right={{ velocities: REP_SET_LAGGING, label: 'Right' }}
202
- variant="rail"
202
+ variant="dual-expanded"
203
203
  height={h}
204
204
  scale="fixed"
205
205
  />
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View } from 'react-native'
3
3
  import { VelocityStrip, DualVelocityStrip } from './VelocityStrip'
4
+ import { FIXED_MAX_VALUE, PEAK_HEADROOM } from '../charts/SetBarChart'
4
5
  import {
5
6
  Sheet,
6
7
  Note,
@@ -216,6 +217,45 @@ export const RepTypes: Story = {
216
217
  ),
217
218
  }
218
219
 
220
+ /**
221
+ * VW-97: `scale="fixed"` used the bare `FIXED_MAX_VALUE` ceiling with no headroom multiplier, so a
222
+ * rep AT the ceiling filled the plot to its very top with no room for its own label — unlike an
223
+ * equally-tall `peak` bar, which always kept `PEAK_HEADROOM`'s margin. `scaleDenominator` now
224
+ * applies the same headroom to both scales.
225
+ *
226
+ * "Before" reproduces the old denominator through the public `scaleMax` prop (`scaleMax *
227
+ * PEAK_HEADROOM` cancels back down to the bare ceiling) rather than a hand-rolled duplicate, so
228
+ * this story exercises the real component on both sides.
229
+ */
230
+ export const FixedScaleHeadroom: Story = {
231
+ name: 'Fixed Scale Headroom (VW-97)',
232
+ decorators: [wallDecorator],
233
+ render: () => (
234
+ <View style={{ flexDirection: 'row', gap: 24 }}>
235
+ <View style={{ gap: 8, flex: 1 }}>
236
+ <ViewLabel text="before · ceiling bar touches the plot top" />
237
+ <VelocityStrip
238
+ velocities={[FIXED_MAX_VALUE, FIXED_MAX_VALUE * 0.6]}
239
+ variant="hero"
240
+ label="Set"
241
+ height={140}
242
+ scaleMax={FIXED_MAX_VALUE / PEAK_HEADROOM}
243
+ />
244
+ </View>
245
+ <View style={{ gap: 8, flex: 1 }}>
246
+ <ViewLabel text="after · fixed reserves the same headroom peak does" />
247
+ <VelocityStrip
248
+ velocities={[FIXED_MAX_VALUE, FIXED_MAX_VALUE * 0.6]}
249
+ variant="hero"
250
+ label="Set"
251
+ height={140}
252
+ scale="fixed"
253
+ />
254
+ </View>
255
+ </View>
256
+ ),
257
+ }
258
+
219
259
  export const EdgeCases: Story = {
220
260
  name: 'Edge Cases',
221
261
  decorators: [wallDecorator],
@@ -9,6 +9,7 @@ import {
9
9
  calculateMeanVelocity,
10
10
  getVelocityLossColor,
11
11
  } from './VelocityStrip'
12
+ import { FIXED_MAX_VALUE } from '../charts/SetBarChart'
12
13
 
13
14
  const sampleVelocities = [1.1, 0.95, 0.82, 0.68, 0.45]
14
15
 
@@ -583,28 +584,69 @@ describe('VelocityStrip expanded bare strip (spotlight: numbers + info off)', ()
583
584
  })
584
585
 
585
586
  it('scales bar height to velocity against the fixed ceiling at the given height', () => {
587
+ const barPx = (id: string) => parseFloat(getComputedStyle(screen.getByTestId(id)).height)
586
588
  render(
587
589
  <VelocityStrip
588
590
  {...bareProps}
589
591
  scale="fixed"
590
592
  height={24}
591
- set={{ type: 'straight', velocities: [1.15, 0.575], planned: 2 }}
593
+ set={{ type: 'straight', velocities: [0.6, 0.3], planned: 2 }}
592
594
  />
593
595
  )
594
- expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '24px' })
595
- expect(screen.getByTestId('velocity-bar-1')).toHaveStyle({ height: '12px' })
596
+ // Proportional to velocity against the fixed ceiling, not the performed max — a 2:1 velocity
597
+ // ratio reads as a 2:1 bar-height ratio.
598
+ expect(barPx('velocity-bar-0')).toBeCloseTo(2 * barPx('velocity-bar-1'), 5)
596
599
  })
597
600
 
598
601
  it('honors height for the scaling', () => {
599
- render(
602
+ const barPx = (id: string) => parseFloat(getComputedStyle(screen.getByTestId(id)).height)
603
+ const { unmount } = render(
600
604
  <VelocityStrip
601
605
  {...bareProps}
602
606
  scale="fixed"
603
607
  height={40}
604
- set={{ type: 'straight', velocities: [1.15], planned: 1 }}
608
+ set={{ type: 'straight', velocities: [0.6], planned: 1 }}
609
+ />
610
+ )
611
+ const at40 = barPx('velocity-bar-0')
612
+ unmount()
613
+ render(
614
+ <VelocityStrip
615
+ {...bareProps}
616
+ scale="fixed"
617
+ height={80}
618
+ set={{ type: 'straight', velocities: [0.6], planned: 1 }}
619
+ />
620
+ )
621
+ expect(barPx('velocity-bar-0')).toBeCloseTo(2 * at40, 5)
622
+ })
623
+
624
+ it('reserves the same headroom above the fixed ceiling that `peak` reserves above its own max (VW-97)', () => {
625
+ // A rep AT the fixed ceiling must NOT fill the plot — it needs the identical margin an
626
+ // equally-tall `peak` bar always keeps for its own value label.
627
+ const { unmount } = render(
628
+ <VelocityStrip
629
+ {...bareProps}
630
+ scale="fixed"
631
+ height={100}
632
+ set={{ type: 'straight', velocities: [FIXED_MAX_VALUE], planned: 1 }}
633
+ />
634
+ )
635
+ const fixedAtCeilingPx = parseFloat(
636
+ getComputedStyle(screen.getByTestId('velocity-bar-0')).height
637
+ )
638
+ unmount()
639
+ render(
640
+ <VelocityStrip
641
+ {...bareProps}
642
+ scale="peak"
643
+ height={100}
644
+ set={{ type: 'straight', velocities: [1], planned: 1 }}
605
645
  />
606
646
  )
607
- expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '40px' })
647
+ const peakAtMaxPx = parseFloat(getComputedStyle(screen.getByTestId('velocity-bar-0')).height)
648
+ expect(fixedAtCeilingPx).toBeLessThan(100)
649
+ expect(fixedAtCeilingPx).toBeCloseTo(peakAtMaxPx, 5)
608
650
  })
609
651
 
610
652
  it('draws planned-but-undone reps as short grey todo stubs (set-type aware)', () => {
@@ -702,9 +702,14 @@ export interface DualVelocityStripProps extends ViewProps {
702
702
  * `hero` — the across-the-room wall scale: tall wings, per-rep m/s value labels, and a dashed
703
703
  * running-best reference line per side. `compact` — the flat resting form: dual-hero but flat +
704
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.
705
+ * gutter + axis. `dual-expanded` — the compact rail-expanded lean renderer, no labels / reference
706
+ * lines (VW-97: `rail` was a verified misnomer — this variant is the value-height strip a session
707
+ * rail row expands into, not the rail itself).
708
+ *
709
+ * `rail` is a **deprecated alias** for `dual-expanded` (same renderer, same output) — kept for one
710
+ * release so existing call sites keep working; see the Workout README.
706
711
  */
707
- variant?: 'hero' | 'compact' | 'rail'
712
+ variant?: 'hero' | 'compact' | 'dual-expanded' | 'rail'
708
713
  /**
709
714
  * Bar-height scaling, shared across BOTH wings so the L/R asymmetry reads as bar
710
715
  * length against one scale. `peak` (default) = the pair's max +headroom; `fixed` =
@@ -926,12 +931,12 @@ function DualVelocityHero({
926
931
  }
927
932
 
928
933
  /**
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.)
934
+ * The `dual-expanded` diverging chart (formerly `rail`) — a lean, compact dedicated renderer.
935
+ * Composing the hero here would drag in its value labels, reference lines, paper, and label
936
+ * headroom, none of which belong at this scale; instead it draws mirrored per-side bars at the
937
+ * compact metrics. Same shared height scale + per-side loss coloring + mirrored radius + shared
938
+ * axis as the hero, but no labels / reference lines / paper. (Set-type slot windows are a
939
+ * hero-composition concern; this renderer only ever takes plain velocities.)
935
940
  */
936
941
  function DualVelocityRail({
937
942
  leftDone,
@@ -1090,11 +1095,14 @@ export function DualVelocityStrip({
1090
1095
  className,
1091
1096
  ...props
1092
1097
  }: DualVelocityStripProps) {
1098
+ // `rail` is the deprecated alias for `dual-expanded` — normalize once so every branch below
1099
+ // reads one canonical value instead of re-checking both spellings.
1100
+ const resolvedVariant = variant === 'rail' ? 'dual-expanded' : variant
1093
1101
  const resolvedHeight =
1094
1102
  height ??
1095
- (variant === 'hero'
1103
+ (resolvedVariant === 'hero'
1096
1104
  ? DUAL_HERO_HEIGHT
1097
- : variant === 'compact'
1105
+ : resolvedVariant === 'compact'
1098
1106
  ? DUAL_COMPACT_HEIGHT
1099
1107
  : DUAL_RAIL_HEIGHT)
1100
1108
  // The done arrays drive the shared max + per-side best + the summary counts; the raw streams
@@ -1123,10 +1131,11 @@ export function DualVelocityStrip({
1123
1131
  viewProps: props,
1124
1132
  }
1125
1133
 
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} />
1134
+ // `liveRepIndex` reaches dual-expanded too: its wings are composed strips, so the newest rep
1135
+ // grows from the midline on BOTH sides. Compact is flat, so a grow animation has nothing to animate.
1136
+ if (resolvedVariant === 'dual-expanded')
1137
+ return <DualVelocityRail {...shared} liveRepIndex={liveRepIndex} />
1138
+ if (resolvedVariant === 'compact') return <DualVelocityCompactStrip {...shared} />
1130
1139
  return <DualVelocityHero {...shared} liveRepIndex={liveRepIndex} />
1131
1140
  }
1132
1141
 
@@ -3,8 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
3
3
  import { ZoneTrack, type ZoneTrackZone } from './ZoneTrack'
4
4
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
5
  import { Surface } from '../../ui/surface'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
7
 
7
8
  const { green, yellow, orange, red } = WORKOUT_TOKENS.scale
9
+ const t = getSemanticColors('dark')
8
10
 
9
11
  const FATIGUE_ZONES: ZoneTrackZone[] = [
10
12
  { upTo: 10, color: green },
@@ -86,7 +88,7 @@ export const BandWithMarker: Story = {
86
88
  ],
87
89
  min: 6,
88
90
  max: 10,
89
- band: { from: 7.2, to: 8.4, color: 'rgba(255,255,255,0.22)' },
91
+ band: { from: 7.2, to: 8.4, color: t['hairline-strong'] },
90
92
  marker: { type: 'needle', value: 8.1 },
91
93
  ticks: [
92
94
  { value: 6, label: '6' },