@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,77 +1,164 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import type { ReactNode } from 'react'
2
3
  import { View, Pressable } from 'react-native'
3
4
  import { Typography } from '../Typography'
5
+ import { cn } from '../../../utils/cn'
4
6
  import { roundWeight } from '../../../utils/workout-format'
7
+ import { useSurfaceMode } from '../../ui/surface/SurfaceContext'
5
8
  import { SetsRepsLoad } from './SetsRepsLoad'
6
9
  import { TempoDisplay } from './TempoDisplay'
7
10
  import { ExerciseIndicator, type ExerciseIndicatorKind } from './ExerciseIndicator'
11
+ import { exerciseLiveColor } from './exerciseRowState'
8
12
 
9
- export interface ExerciseHeadingProps {
13
+ /** Dim depth for a not-yet-reached exercise, when the block dims itself. */
14
+ const DIMMED_OPACITY = 0.55
15
+
16
+ /** `stacked` puts the prescription on its own line under the name; `inline` keeps one row. */
17
+ export type ExerciseHeadingLayout = 'stacked' | 'inline'
18
+
19
+ interface ExerciseHeadingBaseProps {
10
20
  name: string
11
- /** Prescription line values (sets × reps @ load). */
12
- sets: number
13
- reps: number | string
14
- /** Load value; a string (e.g. "—") passes through verbatim for an unset/discovery load. */
15
- load: number | string
16
21
  unit?: 'lbs' | 'kg'
17
- /** Tempo tuple [eccentric, pauseBottom, concentric, pauseTop]; hidden when absent. */
22
+ /** Tempo tuple [eccentric, pauseBottom, concentric, pauseTop]. `stacked` layout only. */
18
23
  tempo?: [number, number, number, number]
19
24
  /** Small PR / issue / info chip in the title row. */
20
25
  indicator?: ExerciseIndicatorKind
21
26
  /** Dim the block as a not-yet-reached exercise (standalone use). */
22
27
  dimmed?: boolean
28
+ /** Row shape. Default `stacked`. */
29
+ layout?: ExerciseHeadingLayout
30
+ /**
31
+ * Render the structured `sets × reps @ load` prescription in one dimmer, regular
32
+ * tone instead of the rail's bright/bold value cells. `stacked` (rail) never sets
33
+ * this; `inline` (compact, upcoming) always does.
34
+ */
35
+ mutedPrescription?: boolean
36
+ /** Trailing right-edge caption (a previous best). `inline` layout only. */
37
+ previousBest?: string
38
+ /** This exercise is being performed right now — the name takes the live tone, never animated. */
39
+ isLive?: boolean
40
+ /**
41
+ * Who owns the press. `self` (default) makes the name row the button. `ancestor`
42
+ * renders it as a plain row, for a wrapper that presses as a whole — legal only in
43
+ * `inline` layout, since `stacked`'s TempoDisplay sibling is itself a Pressable.
44
+ */
45
+ pressTarget?: 'self' | 'ancestor'
23
46
  /** Press target for the name row (expand / select). */
24
47
  onPress?: () => void
25
- }
26
-
27
- function headingLabel(
28
- name: string,
29
- sets: number,
30
- reps: number | string,
31
- load: number | string,
32
- unit: string
33
- ) {
34
- const loadLabel = typeof load === 'number' ? roundWeight(load) : load
35
- return `${name}, ${sets}×${reps} @ ${loadLabel} ${unit}`
48
+ onPressIn?: () => void
49
+ onPressOut?: () => void
50
+ /** Web-only; on a touch surface these never fire and the row has no hover state. */
51
+ onHoverIn?: () => void
52
+ onHoverOut?: () => void
36
53
  }
37
54
 
38
55
  /**
39
- * The exercise-heading info block (no strip): the name + {@link ExerciseIndicator}
40
- * title row over a tight {@link SetsRepsLoad} prescription line beside the real
41
- * {@link TempoDisplay} (`showLabel={false}`). The name row is the sole press
42
- * target — TempoDisplay is a sibling, never a descendant, since it is itself a
43
- * Pressable (nesting would be an a11y nested-interactive violation).
56
+ * The prescription is EITHER the structured `sets × reps @ load` triple, OR a
57
+ * free-text line for an exercise whose numbers aren't loaded yet, OR absent.
58
+ * Mixing the two is a type error rather than a silently half-rendered line.
44
59
  */
45
- export function ExerciseHeading({
60
+ type ExerciseHeadingPrescription =
61
+ | { sets: number; reps: number | string; load: number | string; prescription?: never }
62
+ | { prescription: string; sets?: never; reps?: never; load?: never }
63
+ | { sets?: never; reps?: never; load?: never; prescription?: never }
64
+
65
+ export type ExerciseHeadingProps = ExerciseHeadingBaseProps & ExerciseHeadingPrescription
66
+
67
+ function prescriptionText(p: ExerciseHeadingProps, unit: string): string | undefined {
68
+ if (p.prescription !== undefined) return p.prescription
69
+ if (p.sets === undefined) return undefined
70
+ const loadLabel = typeof p.load === 'number' ? roundWeight(p.load) : p.load
71
+ return `${p.sets}×${p.reps} @ ${loadLabel} ${unit}`
72
+ }
73
+
74
+ /** The row's accessible name. Exported so a wrapper that owns the press can reuse it. */
75
+ export function exerciseHeadingLabel(p: ExerciseHeadingProps, unit: string = p.unit ?? 'lbs') {
76
+ const prescription = prescriptionText(p, unit)
77
+ return prescription ? `${p.name}, ${prescription}` : p.name
78
+ }
79
+
80
+ function pressProps(p: ExerciseHeadingProps, unit: string) {
81
+ return {
82
+ onPress: p.onPress,
83
+ onPressIn: p.onPressIn,
84
+ onPressOut: p.onPressOut,
85
+ onHoverIn: p.onHoverIn,
86
+ onHoverOut: p.onHoverOut,
87
+ accessibilityRole: 'button' as const,
88
+ accessibilityLabel: exerciseHeadingLabel(p, unit),
89
+ }
90
+ }
91
+
92
+ interface NameStyle {
93
+ color: string
94
+ }
95
+
96
+ function HeadingName({
46
97
  name,
47
- sets,
48
- reps,
49
- load,
50
- unit = 'lbs',
51
- tempo,
52
- indicator,
53
- dimmed,
54
- onPress,
55
- }: ExerciseHeadingProps) {
98
+ nameStyle,
99
+ className,
100
+ }: {
101
+ name: string
102
+ nameStyle?: NameStyle
103
+ className?: string
104
+ }) {
105
+ return (
106
+ // The row is a button label, not a heading, so `boldLabel` carries the weight and
107
+ // no header role has to be undone. Size and the Space Grotesk face are pinned.
108
+ <Typography
109
+ variant="boldLabel"
110
+ color={nameStyle ? 'inherit' : 'primary'}
111
+ style={nameStyle}
112
+ className={cn('font-heading text-sm leading-[normal]', className)}
113
+ testID="exercise-card-name"
114
+ >
115
+ {name}
116
+ </Typography>
117
+ )
118
+ }
119
+
120
+ function prescriptionNode(p: ExerciseHeadingProps, unit: string): ReactNode {
121
+ if (p.prescription !== undefined) {
122
+ return (
123
+ <Typography
124
+ variant="caption"
125
+ color="secondary"
126
+ maxLines={1}
127
+ className="font-sans shrink leading-[normal]"
128
+ testID="exercise-card-prescription"
129
+ >
130
+ {p.prescription}
131
+ </Typography>
132
+ )
133
+ }
134
+ if (p.sets === undefined) return null
135
+ return (
136
+ <SetsRepsLoad
137
+ sets={p.sets}
138
+ reps={p.reps}
139
+ load={p.load}
140
+ unit={unit}
141
+ muted={p.mutedPrescription}
142
+ />
143
+ )
144
+ }
145
+
146
+ interface LayoutProps {
147
+ heading: ExerciseHeadingProps
148
+ nameStyle?: NameStyle
149
+ }
150
+
151
+ function StackedHeading({ heading, nameStyle }: LayoutProps) {
152
+ const { unit = 'lbs', tempo, indicator } = heading
56
153
  return (
57
- <View style={{ opacity: dimmed ? 0.55 : 1 }} testID="exercise-heading">
154
+ <>
58
155
  <Pressable
59
- onPress={onPress}
60
- accessibilityRole="button"
61
- accessibilityLabel={headingLabel(name, sets, reps, load, unit)}
156
+ {...pressProps(heading, unit)}
62
157
  className="flex-row items-center"
63
158
  style={{ gap: 8 }}
64
159
  testID="exercise-card-header"
65
160
  >
66
- {/* The row is a button label, not a heading, so `boldLabel` carries the weight and
67
- no header role has to be undone. Size and the Space Grotesk face are pinned. */}
68
- <Typography
69
- variant="boldLabel"
70
- className="font-heading text-sm leading-[normal]"
71
- testID="exercise-card-name"
72
- >
73
- {name}
74
- </Typography>
161
+ <HeadingName name={heading.name} nameStyle={nameStyle} />
75
162
  <View className="flex-1" />
76
163
  {indicator && <ExerciseIndicator kind={indicator} />}
77
164
  </Pressable>
@@ -81,10 +168,68 @@ export function ExerciseHeading({
81
168
  style={{ gap: 8, marginTop: 1 }}
82
169
  testID="exercise-card-summary"
83
170
  >
84
- <SetsRepsLoad sets={sets} reps={reps} load={load} unit={unit} />
171
+ {prescriptionNode(heading, unit)}
85
172
  <View className="flex-1" />
86
173
  {tempo && <TempoDisplay tempo={tempo} size="sm" showLabel={false} showInfo={false} />}
87
174
  </View>
175
+ </>
176
+ )
177
+ }
178
+
179
+ function InlineHeading({ heading, nameStyle }: LayoutProps) {
180
+ const { unit = 'lbs', indicator, previousBest } = heading
181
+ const Row = heading.pressTarget === 'ancestor' ? View : Pressable
182
+ return (
183
+ <Row
184
+ {...(heading.pressTarget === 'ancestor' ? {} : pressProps(heading, unit))}
185
+ className="flex-row items-center"
186
+ style={{ gap: 8 }}
187
+ testID="exercise-card-header"
188
+ >
189
+ {/* Name never truncates (no numberOfLines); prescription + previousBest ellipsize first. */}
190
+ <HeadingName name={heading.name} nameStyle={nameStyle} className="shrink-0" />
191
+ <View className="shrink flex-row items-center" testID="exercise-card-summary">
192
+ {prescriptionNode(heading, unit)}
193
+ </View>
194
+ <View className="flex-1" style={{ minWidth: 8 }} />
195
+ {indicator && <ExerciseIndicator kind={indicator} />}
196
+ {previousBest && (
197
+ <Typography
198
+ variant="caption"
199
+ color="tertiary"
200
+ maxLines={1}
201
+ className="font-sans shrink text-2xs leading-[normal]"
202
+ testID="exercise-card-previous-best"
203
+ >
204
+ {previousBest}
205
+ </Typography>
206
+ )}
207
+ </Row>
208
+ )
209
+ }
210
+
211
+ /**
212
+ * The exercise-heading info block (no strip): the name + {@link ExerciseIndicator}
213
+ * title row, its prescription line, and — `inline` only — a trailing previous-best
214
+ * caption. The name row is the sole press target: TempoDisplay and the previous-best
215
+ * caption are siblings of it, never descendants, since TempoDisplay is itself a
216
+ * Pressable (nesting would be an a11y nested-interactive violation).
217
+ *
218
+ * `layout` is the one axis that separates the session-rail row (`stacked`) from the
219
+ * collapsed and upcoming card rows (`inline`) — same parts, same vocabulary, one row
220
+ * instead of two. `inline` drops the tempo: it has no room for a second metric lockup.
221
+ */
222
+ export function ExerciseHeading(props: ExerciseHeadingProps) {
223
+ const mode = useSurfaceMode()
224
+ const nameStyle = props.isLive ? { color: exerciseLiveColor(mode) } : undefined
225
+
226
+ return (
227
+ <View style={{ opacity: props.dimmed ? DIMMED_OPACITY : 1 }} testID="exercise-heading">
228
+ {props.layout === 'inline' ? (
229
+ <InlineHeading heading={props} nameStyle={nameStyle} />
230
+ ) : (
231
+ <StackedHeading heading={props} nameStyle={nameStyle} />
232
+ )}
88
233
  </View>
89
234
  )
90
235
  }
@@ -7,6 +7,7 @@ import {
7
7
  type ExerciseIndicatorKind,
8
8
  } from './ExerciseIndicator'
9
9
  import { Surface } from '../../ui/surface'
10
+ import { greyRamp } from '../../../theme/tokens/primitives'
10
11
 
11
12
  /**
12
13
  * `ExerciseIndicator` — a small circular OUTLINED chip in an exercise heading's
@@ -70,9 +71,9 @@ export const AllKinds: Story = {
70
71
  <View style={{ gap: 12 }}>
71
72
  {INDICATOR_PRECEDENCE.map((kind, i) => (
72
73
  <View key={kind} style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
73
- <Text style={{ color: '#8A8A8A', fontSize: 11, width: 14 }}>{i + 1}</Text>
74
+ <Text style={{ color: greyRamp[500], fontSize: 11, width: 14 }}>{i + 1}</Text>
74
75
  <ExerciseIndicator kind={kind} />
75
- <Text style={{ color: '#CFCFCF', fontSize: 12 }}>{KIND_LABELS[kind]}</Text>
76
+ <Text style={{ color: greyRamp[200], fontSize: 12 }}>{KIND_LABELS[kind]}</Text>
76
77
  </View>
77
78
  ))}
78
79
  </View>
@@ -97,12 +98,12 @@ export const ResolvePrecedence: Story = {
97
98
  const winner = resolveIndicator(candidates)
98
99
  return (
99
100
  <View key={i} style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
100
- <Text style={{ color: '#8A8A8A', fontSize: 12, width: 200 }}>
101
+ <Text style={{ color: greyRamp[500], fontSize: 12, width: 200 }}>
101
102
  {candidates.join(', ')}
102
103
  </Text>
103
- <Text style={{ color: '#8A8A8A', fontSize: 12 }}>→</Text>
104
+ <Text style={{ color: greyRamp[500], fontSize: 12 }}>→</Text>
104
105
  {winner ? <ExerciseIndicator kind={winner} /> : null}
105
- <Text style={{ color: '#CFCFCF', fontSize: 12 }}>{winner}</Text>
106
+ <Text style={{ color: greyRamp[200], fontSize: 12 }}>{winner}</Text>
106
107
  </View>
107
108
  )
108
109
  })}
@@ -18,7 +18,9 @@ export interface InputBarProps {
18
18
  visible: boolean
19
19
  }
20
20
 
21
- const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
21
+ const t = getSemanticColors('dark')
22
+ const BRAND_PRIMARY = t['brand-primary']
23
+ const ON_BRAND_PRIMARY = t['on-brand-primary']
22
24
  const INPUT_CLASSNAME = 'bg-surface-raised border-hairline-strong text-text-primary'
23
25
 
24
26
  const inputStyle = {
@@ -151,7 +153,7 @@ export function InputBar({
151
153
  fontSize: 13,
152
154
  fontWeight: '700',
153
155
  fontFamily: 'Inter, sans-serif',
154
- color: '#FFFFFF',
156
+ color: ON_BRAND_PRIMARY,
155
157
  }}
156
158
  >
157
159
  Record
@@ -11,6 +11,7 @@ import {
11
11
  import { getSemanticColors } from '../../../theme/tokens/semantic'
12
12
  import { getGlowShadow } from '../../../theme/elevation'
13
13
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
14
+ import { alpha } from '../../../utils/colors'
14
15
 
15
16
  const t = getSemanticColors('dark')
16
17
 
@@ -32,9 +33,11 @@ export interface IntensityBarProps extends ViewProps {
32
33
  className?: string
33
34
  }
34
35
 
36
+ // VW-82: neither value is on a ramp (nearest surface-raised #31302F / greyRamp[600]
37
+ // #72716F). Left raw pending the proposed roles — snapping them is a visual change.
35
38
  const TRACK_BG = '#333333'
36
39
  const LABEL_COLOR = '#6B7280'
37
- const TARGET_LINE_COLOR = 'rgba(33, 150, 243, 0.5)'
40
+ const TARGET_LINE_COLOR = alpha(t['status-info'], 0.5)
38
41
  /** At-target is EMPHASIS, not depth: an info-toned glow through the shared builder. */
39
42
  const AT_TARGET_GLOW = getGlowShadow(t['status-info'], 'subtle')
40
43
 
@@ -10,6 +10,7 @@ import {
10
10
  MESO_ACCENT_GRADIENT_LIGHT,
11
11
  } from '../../../theme/extracted-colors-dataviz'
12
12
  import { getGlowShadow } from '../../../theme/elevation'
13
+ import { alpha } from '../../../utils/colors'
13
14
 
14
15
  const HAIRLINE_DEFAULT = getSemanticColors('dark')['hairline-default']
15
16
 
@@ -61,7 +62,7 @@ export interface MesoCardProps extends ViewProps {
61
62
  function heatmapColor(percentage: number): string {
62
63
  const clamped = Math.max(0, Math.min(100, percentage))
63
64
  const opacity = 0.12 + (clamped / 100) * 0.78
64
- return `rgba(255,121,0,${opacity.toFixed(3)})`
65
+ return alpha(BRAND_PRIMARY, opacity)
65
66
  }
66
67
 
67
68
  /**
@@ -5,11 +5,13 @@ import { Card } from '../../ui/card'
5
5
  import { StatusDot } from './StatusDot'
6
6
  import { resolveColor } from '../../../theme/resolve-color'
7
7
  import { getSemanticColors } from '../../../theme/tokens/semantic'
8
+ import { primitiveColors } from '../../../theme/tokens/primitives'
8
9
  import {
9
10
  MESO_ACCENT_GRADIENT_DARK,
10
11
  MESO_ACCENT_GRADIENT_LIGHT,
11
12
  } from '../../../theme/extracted-colors-dataviz'
12
13
  import { liftStyle } from '../../../theme/lift'
14
+ import { alpha } from '../../../utils/colors'
13
15
 
14
16
  const t = getSemanticColors('dark')
15
17
 
@@ -32,8 +34,7 @@ const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
32
34
  const CARD_GRADIENT = `linear-gradient(135deg, ${resolveColor('surface-elevated')} 0%, ${resolveColor('surface-raised')} 100%)`
33
35
 
34
36
  /** Gauge track gradient (teal -> amber -> red) at 0.25 alpha. */
35
- const GAUGE_GRADIENT =
36
- 'linear-gradient(90deg, rgba(46,213,115,0.25) 0%, rgba(249,180,21,0.25) 50%, rgba(209,67,67,0.25) 100%)'
37
+ const GAUGE_GRADIENT = `linear-gradient(90deg, ${alpha(SUCCESS, 0.25)} 0%, ${alpha(WARNING, 0.25)} 50%, ${alpha(ERROR, 0.25)} 100%)`
37
38
 
38
39
  export type MesoStatusBadgeVariant = 'success' | 'warning' | 'error'
39
40
 
@@ -41,9 +42,9 @@ const STATUS_VARIANTS: Record<
41
42
  MesoStatusBadgeVariant,
42
43
  { bg: string; border: string; text: string }
43
44
  > = {
44
- success: { bg: 'rgba(46,213,115,0.15)', border: 'rgba(46,213,115,0.3)', text: SUCCESS },
45
- warning: { bg: 'rgba(249,180,21,0.15)', border: 'rgba(249,180,21,0.3)', text: WARNING },
46
- error: { bg: 'rgba(209,67,67,0.15)', border: 'rgba(209,67,67,0.25)', text: ERROR },
45
+ success: { bg: alpha(SUCCESS, 0.15), border: alpha(SUCCESS, 0.3), text: SUCCESS },
46
+ warning: { bg: alpha(WARNING, 0.15), border: alpha(WARNING, 0.3), text: WARNING },
47
+ error: { bg: alpha(ERROR, 0.15), border: alpha(ERROR, 0.25), text: ERROR },
47
48
  }
48
49
 
49
50
  export interface MesoStatusBadge {
@@ -257,7 +258,7 @@ function Gauge({ gauge }: { gauge: MesoStatusGauge }) {
257
258
  bottom: 0,
258
259
  width: 1,
259
260
  marginLeft: -0.5,
260
- backgroundColor: 'rgba(255,255,255,0.3)',
261
+ backgroundColor: alpha(primitiveColors.white, 0.3),
261
262
  }}
262
263
  testID="meso-status-card-gauge-center"
263
264
  />
@@ -393,9 +394,9 @@ export function MesoStatusCard({
393
394
  {coaching != null && (
394
395
  <View
395
396
  style={{
396
- backgroundColor: 'rgba(249,180,21,0.06)',
397
+ backgroundColor: alpha(WARNING, 0.06),
397
398
  borderWidth: 1,
398
- borderColor: 'rgba(249,180,21,0.15)',
399
+ borderColor: alpha(WARNING, 0.15),
399
400
  borderRadius: 8,
400
401
  paddingVertical: 10,
401
402
  paddingHorizontal: 12,
@@ -420,9 +421,9 @@ export function MesoStatusCard({
420
421
  className="flex-row items-center"
421
422
  style={{
422
423
  gap: 8,
423
- backgroundColor: 'rgba(46,213,115,0.06)',
424
+ backgroundColor: alpha(SUCCESS, 0.06),
424
425
  borderWidth: 1,
425
- borderColor: 'rgba(46,213,115,0.2)',
426
+ borderColor: alpha(SUCCESS, 0.2),
426
427
  borderRadius: 8,
427
428
  paddingVertical: 10,
428
429
  paddingHorizontal: 12,
@@ -1,6 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { View } from 'react-native'
3
3
  import { PlaceholderStrip } from './PlaceholderStrip'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
+
6
+ const t = getSemanticColors('dark')
4
7
 
5
8
  const meta: Meta<typeof PlaceholderStrip> = {
6
9
  title: 'Custom/Workout/PlaceholderStrip',
@@ -74,9 +77,11 @@ export const SegmentedThree: Story = {
74
77
  export const MultipleStrips: Story = {
75
78
  render: () => (
76
79
  <View style={{ flexDirection: 'row', gap: 4, width: 300 }}>
77
- <View style={{ flex: 1, height: 3, backgroundColor: '#2ed573', borderRadius: 1 }} />
78
- <View style={{ flex: 1, height: 3, backgroundColor: '#ffd43b', borderRadius: 1 }} />
79
- <View style={{ flex: 1, height: 3, backgroundColor: '#ffa502', borderRadius: 1 }} />
80
+ <View style={{ flex: 1, height: 3, backgroundColor: t['status-success'], borderRadius: 1 }} />
81
+ <View
82
+ style={{ flex: 1, height: 3, backgroundColor: t['status-warning-light'], borderRadius: 1 }}
83
+ />
84
+ <View style={{ flex: 1, height: 3, backgroundColor: t['status-warning'], borderRadius: 1 }} />
80
85
  <PlaceholderStrip />
81
86
  <PlaceholderStrip />
82
87
  </View>
@@ -2,6 +2,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { useState } from 'react'
3
3
  import { View, Text, Pressable } from 'react-native'
4
4
  import { PrHistoryModal, type PrRecord } from './PrHistoryModal'
5
+ import { primitiveColors } from '../../../theme/tokens/primitives'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+
8
+ const t = getSemanticColors('dark')
5
9
 
6
10
  const records: PrRecord[] = [
7
11
  { type: 'e1rm', value: 245, unit: 'lbs', date: 'Mar 14', isRecent: true },
@@ -93,13 +97,19 @@ function InteractivePrHistoryModal() {
93
97
  accessibilityRole="button"
94
98
  style={{
95
99
  alignSelf: 'flex-start',
96
- backgroundColor: '#FF7900',
100
+ backgroundColor: t['brand-primary'],
97
101
  paddingVertical: 10,
98
102
  paddingHorizontal: 20,
99
103
  borderRadius: 8,
100
104
  }}
101
105
  >
102
- <Text style={{ color: '#FFFFFF', fontWeight: '700', fontFamily: 'Inter, sans-serif' }}>
106
+ <Text
107
+ style={{
108
+ color: primitiveColors.white,
109
+ fontWeight: '700',
110
+ fontFamily: 'Inter, sans-serif',
111
+ }}
112
+ >
103
113
  Open PR history
104
114
  </Text>
105
115
  </Pressable>
@@ -86,11 +86,50 @@ type props without pulling the dependency.
86
86
  └─ SetStrip
87
87
  └─ SetBar × N (one set's per-rep colour bar)
88
88
 
89
- ExerciseCard state="rail" ──delegates to──▶ ExerciseCardHeading
89
+ ExerciseCard (all three representations) ──delegates to──▶ ExerciseCardHeading
90
90
  ```
91
91
 
92
- `ExerciseCard`'s `rail` state is now a thin adapter that delegates to
93
- `ExerciseCardHeading` (so the heading is reusable without the card). `SetBar`
92
+ **TD-03.55 / TD-03.56 one row, three densities, and interaction states.**
93
+ `ExerciseCardHeading` IS the exercise row. Its `density` prop carries the only
94
+ axis that ever separated the three call sites:
95
+
96
+ | `density` | shape | was |
97
+ | ---------- | ------------------------------------------------------- | ---------------------------- |
98
+ | `rail` | two lines: name, then prescription beside the tempo | `ExerciseCardHeading` |
99
+ | `compact` | one line: name + prescription, strip below | `ExerciseCard`'s CollapsedCard |
100
+ | `upcoming` | `compact`, dimmed, previous best pinned right | `ExerciseCard`'s UpcomingCard |
101
+
102
+ `CollapsedCard` and `UpcomingCard` are deleted; `ExerciseCard` maps its props onto
103
+ the row, so **its own prop shape is unchanged** and no consumer migrates. Three
104
+ things converged in the process, each a consistency gain rather than a new idea:
105
+ the collapsed row's freeform `3×6 @ 175 lbs` caption became the shared
106
+ `SetsRepsLoad` line; its hand-rolled `VelocityStrip` + `PlaceholderStrip` row
107
+ became one `SetStrip` (so `velocityZones` no longer tints the collapsed glance —
108
+ the expanded header already ignored it); and `isPR` surfaces through the
109
+ `ExerciseIndicator` chip, which is what the expanded header already did.
110
+
111
+ The prescription is a **discriminated union**: either the structured
112
+ `sets`/`reps`/`load` triple, or a free-text `prescription` string for an exercise
113
+ whose numbers aren't loaded, or neither. Mixing them is a type error rather than a
114
+ half-rendered line.
115
+
116
+ Interaction states are `interactive-*` token washes on the row —
117
+ **press > selection > hover** — plus an `isLive` name tone (`status-live`). Every
118
+ one is static: this row renders on the wall during a set, where titan's "the grain
119
+ never animates" rule applies. Hover is web-only; RN Pressable's `onHoverIn` never
120
+ fires on a touch surface, so a touch consumer simply gets no hover state and
121
+ `isSelected` carries the same "this one" meaning. Selection has **no ARIA**:
122
+ `aria-selected` is not allowed on `role="button"`, so the wash is the whole signal
123
+ and list semantics stay the rail's job.
124
+
125
+ Two follow-ups are deliberately NOT taken here. The dim depths still differ by
126
+ density (rail 0.55, card 0.60) because they came from two specimens and converging
127
+ them needs an **opacity token the set does not have** — a foundations decision.
128
+ And `exerciseRowState.ts` (the `interactive-*` → literal-hex map, written in the
129
+ shape of `onSurfaceColors`) should be **promoted into the surface module** the
130
+ moment a second family needs row washes.
131
+
132
+ `SetBar`
94
133
  owns the per-set colour/pulse logic; `SetStrip` lays several side by side.
95
134
  Beyond the flat `done`/`active`/`todo` sets, `SetStripSet` models set-type
96
135
  prescriptions: `range` (variable rep-range — range-max segments, committed-todo grey
@@ -130,7 +169,7 @@ type props without pulling the dependency.
130
169
  array (unchanged, still the source of truth for `SetRow` / `ExerciseCard`), the
131
170
  strip accepts an optional structured `VelocitySet` descriptor and renders the
132
171
  strength set-type vocabulary as a typed slot list. Slots are
133
- `rep` (velocity-coloured) · `todo` (charcoal-300 grey) · `variable` /
172
+ `rep` (velocity-coloured) · `todo` (`border-prominent` grey) · `variable` /
134
173
  `continue` (`SET_STRIP_VARIABLE_COLOR` cyan-900; `continue` adds a cyan-800
135
174
  outline to read as "keep going"). Types: `straight` (done + grey todo to
136
175
  `planned`), `range` (committed grey + a cyan variable window `floor..max`),
@@ -182,14 +221,22 @@ Modalities`** (each card carries a `Collapse` accordion; promoted from the
182
221
  when neither side carries one the gutter is omitted entirely. The label keeps the prior
183
222
  vertical (rotated) orientation so it never overlaps the bars. Two scales: `hero` (tall
184
223
  wings, per-rep m/s velocity labels, a dashed running-best reference line per side) and
185
- `rail` (compact — no velocity labels / reference lines, slot names in a narrow gutter).
224
+ `dual-expanded` (compact — no velocity labels / reference lines, slot names in a narrow
225
+ gutter).
186
226
  **Rep-index alignment is the invariant:** column _i_ is rep _i_ on
187
227
  both sides, so the set-type slot _kinds_ carry through (rep / todo / variable / continue,
188
228
  coloured as in the single strip) but the wide-notch chunk **gaps** (drop / myo / cluster
189
229
  boundaries) are intentionally NOT rendered — per-side horizontal gaps would break the
190
230
  mirrored L↔R column alignment. Single-voltra sets keep using `VelocityStrip`
191
- `variant="hero"`. Documented by the `Playground` / `Hero*` / `Rail` stories on the wall
192
- background (`Custom/Workout/DataViz/DualVelocityStrip`).
231
+ `variant="hero"`. Documented by the `Playground` / `Hero*` / `DualExpanded` stories on the
232
+ wall background (`Custom/Workout/DataViz/DualVelocityStrip`).
233
+
234
+ **`variant="rail"` → `variant="dual-expanded"` (VW-97).** `rail` was a verified misnomer —
235
+ this variant is the value-height strip a session-rail row _expands into_, not the rail
236
+ itself (the rail stays 246px and never changes width; expansion lives in a separate detail
237
+ pane, per the VW-97 rail-width decision). `rail` is kept as a **deprecated alias** for one
238
+ release (same renderer, same output) so existing call sites keep working; migrate to
239
+ `dual-expanded` and expect `rail` to be removed in a future release.
193
240
 
194
241
  **Reuse audit — `DashedReferenceLine` (in-file today, top-level follow-up).** The dashed
195
242
  running-best line was hand-rolled three times inside `VelocityStrip.tsx` (the single
@@ -303,8 +350,7 @@ responsive level views land (removal is out of scope for this scaffold ticket):
303
350
  (Workout Expansion), `S3SetTypes` (Set Types), `S3SessionPace` (Session Pace).
304
351
  Each is superseded by a real Shell/SessionRail component + its Storybook stories;
305
352
  `S3SessionPace` in particular is superseded by the `SessionHeader` pace glance.
306
- - **`ExerciseCard`'s `collapsed` / `upcoming` state representations** — the rail now
307
- owns the live-list heading (`rail` state `ExerciseCardHeading`) and the expanded
308
- view is moving to `ExpandedDrawer`. Once the responsive unification (TD-03.56)
309
- lands a single responsive card, the `collapsed`/`upcoming` branches of
310
- `ExerciseCard` are the superseded representations to retire.
353
+ - **`ExerciseCard`'s `collapsed` / `upcoming` state representations** — DONE
354
+ (TD-03.56). Both hand-rolled sub-cards are deleted; `ExerciseCard` now delegates
355
+ all three representations to `ExerciseCardHeading` and its `density` prop. What
356
+ remains to retire is the expanded view, which is moving to `ExpandedDrawer`.
@@ -3,11 +3,13 @@ import { View, Text, Pressable } from 'react-native'
3
3
  import { Card } from '../../ui/card'
4
4
  import { Badge, type BadgeColor } from '../../ui/badge'
5
5
  import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+ import { alpha } from '../../../utils/colors'
6
7
 
7
8
  const t = getSemanticColors('dark')
8
9
 
9
10
  const BRAND_PRIMARY = t['brand-primary']
10
- const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
11
+ const ON_BRAND_PRIMARY = t['on-brand-primary']
12
+ const BRAND_PRIMARY_SUBTLE = alpha(BRAND_PRIMARY, 0.12)
11
13
  const STATUS_SUCCESS = t['status-success']
12
14
  const STATUS_WARNING = t['status-warning']
13
15
  const STATUS_ERROR = t['status-error']
@@ -274,7 +276,7 @@ export function ReadinessCheck({
274
276
  fontSize: 13,
275
277
  fontWeight: '700',
276
278
  fontFamily: 'Inter, sans-serif',
277
- color: '#FFFFFF',
279
+ color: ON_BRAND_PRIMARY,
278
280
  }}
279
281
  >
280
282
  Start Workout