@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,7 +1,9 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import { ExerciseCardHeading } from './ExerciseCardHeading'
5
+ import { exerciseLiveColor, exerciseRowStateColor } from './exerciseRowState'
6
+ import { onSurfaceColors } from '../../ui/surface/SurfaceContext'
5
7
  import type { SetStripSet } from './SetStrip'
6
8
 
7
9
  const setStates: SetStripSet[] = [
@@ -62,11 +64,197 @@ describe('ExerciseCardHeading', () => {
62
64
  expect(onPress).toHaveBeenCalledOnce()
63
65
  })
64
66
 
67
+ // The three former call sites, each proven to render from the one component.
68
+ describe('density', () => {
69
+ it('rail: the prescription sits on its own line beside the tempo', () => {
70
+ render(<ExerciseCardHeading {...baseProps} />)
71
+ const header = screen.getByTestId('exercise-card-header')
72
+ expect(header).not.toContainElement(screen.getByTestId('exercise-card-summary'))
73
+ expect(screen.getByTestId('tempo-display')).toBeInTheDocument()
74
+ })
75
+
76
+ it('compact: name and prescription share the header row, and the tempo is dropped', () => {
77
+ render(<ExerciseCardHeading {...baseProps} density="compact" />)
78
+ const header = screen.getByTestId('exercise-card-header')
79
+ expect(header).toContainElement(screen.getByTestId('exercise-card-name'))
80
+ expect(header).toContainElement(screen.getByTestId('exercise-card-summary'))
81
+ expect(screen.queryByTestId('tempo-display')).not.toBeInTheDocument()
82
+ })
83
+
84
+ it('compact: the prescription reads "3×6 @ 185 lbs" with no spaces around the ×', () => {
85
+ render(
86
+ <ExerciseCardHeading
87
+ name="Bench Press"
88
+ density="compact"
89
+ sets={3}
90
+ reps={6}
91
+ load={185}
92
+ unit="lbs"
93
+ />
94
+ )
95
+ expect(screen.getByTestId('sets-reps-load')).toHaveTextContent('3×6 @ 185 lbs')
96
+ })
97
+
98
+ it("compact: the prescription numbers carry the secondary tone and regular weight, not the rail's bright bold", () => {
99
+ render(
100
+ <ExerciseCardHeading
101
+ name="Bench Press"
102
+ density="compact"
103
+ sets={3}
104
+ reps={6}
105
+ load={185}
106
+ unit="lbs"
107
+ />
108
+ )
109
+ const secondary = onSurfaceColors('dark').secondary
110
+ for (const text of ['3', '6', '185']) {
111
+ const el = screen.getByText(text)
112
+ expect(el).toHaveStyle({ color: secondary })
113
+ // jsdom's getComputedStyle doesn't resolve numeric font-weight, so read the
114
+ // inline style directly rather than through toHaveStyle.
115
+ expect(el.style.fontWeight).toBe('400')
116
+ }
117
+ })
118
+
119
+ it('rail: the prescription numbers keep the bright bold rail treatment', () => {
120
+ render(<ExerciseCardHeading {...baseProps} />)
121
+ const primary = onSurfaceColors('dark').primary
122
+ const el = screen.getByText('3')
123
+ expect(el).toHaveStyle({ color: primary })
124
+ expect(el.style.fontWeight).toBe('600')
125
+ })
126
+
127
+ it('upcoming: dims itself and carries the free-text prescription + previous best', () => {
128
+ render(
129
+ <ExerciseCardHeading
130
+ name="Deadlift"
131
+ density="upcoming"
132
+ prescription="3×8-12 @ RPE 8"
133
+ previousBest="185 lbs × 10"
134
+ />
135
+ )
136
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({ opacity: 0.6 })
137
+ expect(screen.getByTestId('exercise-card-prescription')).toHaveTextContent('3×8-12 @ RPE 8')
138
+ expect(screen.getByTestId('exercise-card-previous-best')).toHaveTextContent('185 lbs × 10')
139
+ })
140
+
141
+ it('upcoming: an explicit dimmed=false overrides the density default', () => {
142
+ render(<ExerciseCardHeading name="Deadlift" density="upcoming" dimmed={false} />)
143
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({ opacity: 1 })
144
+ })
145
+
146
+ // The fourth call site: voltras-mcp's SPA recap card, which takes a STRING load for
147
+ // a weightless mode rather than fabricating a 0 (VMCP-03.05).
148
+ it('rail: a string load and a testID override still render standalone', () => {
149
+ render(
150
+ <ExerciseCardHeading
151
+ name="Cable Row"
152
+ sets={3}
153
+ reps={10}
154
+ load="—"
155
+ unit="lbs"
156
+ indicator="velocity-loss"
157
+ setStates={[{ status: 'done', velocities: [0.8, 0.7] }]}
158
+ testID="recap-card-heading"
159
+ />
160
+ )
161
+ expect(screen.getByTestId('recap-card-heading')).toBeInTheDocument()
162
+ expect(screen.getByTestId('exercise-card-summary')).toHaveTextContent('—')
163
+ })
164
+ })
165
+
166
+ describe('interaction states', () => {
167
+ it('has no wash at rest', () => {
168
+ render(<ExerciseCardHeading {...baseProps} />)
169
+ expect(screen.getByTestId('exercise-card')).not.toHaveStyle({
170
+ backgroundColor: exerciseRowStateColor('hovered'),
171
+ })
172
+ })
173
+
174
+ it('washes on hover of the heading row', () => {
175
+ render(<ExerciseCardHeading {...baseProps} />)
176
+ fireEvent.mouseEnter(screen.getByTestId('exercise-card-header'))
177
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({
178
+ backgroundColor: exerciseRowStateColor('hovered'),
179
+ })
180
+ })
181
+
182
+ it('clears the hover wash when the pointer leaves', () => {
183
+ render(<ExerciseCardHeading {...baseProps} />)
184
+ const header = screen.getByTestId('exercise-card-header')
185
+ fireEvent.mouseEnter(header)
186
+ fireEvent.mouseLeave(header)
187
+ expect(screen.getByTestId('exercise-card')).not.toHaveStyle({
188
+ backgroundColor: exerciseRowStateColor('hovered'),
189
+ })
190
+ })
191
+
192
+ it('washes while selected, and the selection outranks a hover', () => {
193
+ render(<ExerciseCardHeading {...baseProps} isSelected />)
194
+ fireEvent.mouseEnter(screen.getByTestId('exercise-card-header'))
195
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({
196
+ backgroundColor: exerciseRowStateColor('selected'),
197
+ })
198
+ })
199
+
200
+ // RNW's Pressability grants the responder before it schedules onPressIn, so the
201
+ // wash lands a tick after the mousedown.
202
+ it('a press outranks the selection wash', async () => {
203
+ render(<ExerciseCardHeading {...baseProps} isSelected onPress={vi.fn()} />)
204
+ fireEvent.mouseDown(screen.getByTestId('exercise-card-header'), { button: 0, detail: 1 })
205
+ await waitFor(() =>
206
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({
207
+ backgroundColor: exerciseRowStateColor('pressed'),
208
+ })
209
+ )
210
+ })
211
+
212
+ it('tints the name with the live tone while the exercise is being performed', () => {
213
+ render(<ExerciseCardHeading {...baseProps} isLive />)
214
+ expect(screen.getByTestId('exercise-card-name')).toHaveStyle({
215
+ color: exerciseLiveColor(),
216
+ })
217
+ })
218
+
219
+ it('leaves the name on the primary tone when not live', () => {
220
+ render(<ExerciseCardHeading {...baseProps} />)
221
+ expect(screen.getByTestId('exercise-card-name')).not.toHaveStyle({
222
+ color: exerciseLiveColor(),
223
+ })
224
+ })
225
+
226
+ // The rail renders on the wall during a set; titan's "the grain is static" rule
227
+ // makes a live state a tone change, never motion.
228
+ it('the live state adds no animation or transition', () => {
229
+ const { container } = render(<ExerciseCardHeading {...baseProps} isLive />)
230
+ expect(container.innerHTML).not.toMatch(/animation|transition/i)
231
+ })
232
+ })
233
+
65
234
  describe('accessibility', () => {
66
235
  it('has no accessibility violations', async () => {
67
236
  const { container } = render(<ExerciseCardHeading {...baseProps} onPress={vi.fn()} />)
68
237
  const results = await axe(container)
69
238
  expect(results).toHaveNoViolations()
70
239
  })
240
+
241
+ it('has no accessibility violations in the upcoming density', async () => {
242
+ const { container } = render(
243
+ <ExerciseCardHeading
244
+ name="Deadlift"
245
+ density="upcoming"
246
+ prescription="3×8-12 @ RPE 8"
247
+ previousBest="185 lbs × 10"
248
+ onPress={vi.fn()}
249
+ />
250
+ )
251
+ const results = await axe(container)
252
+ expect(results).toHaveNoViolations()
253
+ })
254
+
255
+ it('names the row from its prescription, structured or free-text', () => {
256
+ render(<ExerciseCardHeading {...baseProps} onPress={vi.fn()} />)
257
+ expect(screen.getByLabelText('Cable Chest Press, 3×10 @ 90 lbs')).toBeInTheDocument()
258
+ })
71
259
  })
72
260
  })
@@ -1,43 +1,192 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { View } from 'react-native'
3
- import { ExerciseHeading, type ExerciseHeadingProps } from './ExerciseHeading'
2
+ import { useState } from 'react'
3
+ import { Pressable, View, type StyleProp, type ViewStyle } from 'react-native'
4
+ import type { ThemeMode } from '../../../theme/tokens/semantic'
5
+ import { useSurfaceMode } from '../../ui/surface/SurfaceContext'
6
+ import { useHoverState } from '../../ui/tooltip'
7
+ import { ExerciseHeading, exerciseHeadingLabel, type ExerciseHeadingProps } from './ExerciseHeading'
4
8
  import { SetStrip, type SetStripSet } from './SetStrip'
9
+ import { exerciseRowStateColor } from './exerciseRowState'
5
10
 
6
- export interface ExerciseCardHeadingProps extends ExerciseHeadingProps {
11
+ /**
12
+ * How much room the row gets, and therefore which shape it takes:
13
+ * - `rail` — the session-rail row: two lines (name, then prescription + tempo).
14
+ * - `compact` — the collapsed card row: one line, card padding.
15
+ * - `upcoming` — `compact`, dimmed, with the previous-best caption.
16
+ */
17
+ export type ExerciseRowDensity = 'rail' | 'compact' | 'upcoming'
18
+
19
+ interface DensitySpec {
20
+ paddingVertical: number
21
+ paddingHorizontal: number
22
+ layout: 'stacked' | 'inline'
23
+ dimOpacity: number
24
+ dimByDefault: boolean
25
+ /** The structured prescription renders in one dimmer, regular tone, not the rail's bright/bold. */
26
+ mutedPrescription: boolean
27
+ }
28
+
29
+ // The two dim depths are inherited from two different specimens (rail 0.55, card 0.60).
30
+ // Converging them needs an opacity token the set does not have — reported, not invented.
31
+ const DENSITY: Record<ExerciseRowDensity, DensitySpec> = {
32
+ rail: {
33
+ paddingVertical: 9,
34
+ paddingHorizontal: 12,
35
+ layout: 'stacked',
36
+ dimOpacity: 0.55,
37
+ dimByDefault: false,
38
+ mutedPrescription: false,
39
+ },
40
+ compact: {
41
+ paddingVertical: 12,
42
+ paddingHorizontal: 14,
43
+ layout: 'inline',
44
+ dimOpacity: 0.55,
45
+ dimByDefault: false,
46
+ mutedPrescription: true,
47
+ },
48
+ upcoming: {
49
+ paddingVertical: 12,
50
+ paddingHorizontal: 14,
51
+ layout: 'inline',
52
+ dimOpacity: 0.6,
53
+ dimByDefault: true,
54
+ mutedPrescription: true,
55
+ },
56
+ }
57
+
58
+ export type ExerciseCardHeadingProps = ExerciseHeadingProps & {
59
+ /** Row shape. Default `rail`. */
60
+ density?: ExerciseRowDensity
7
61
  /** Per-set performance data driving the strip; an empty list renders no strip. */
8
- setStates: SetStripSet[]
62
+ setStates?: SetStripSet[]
9
63
  /** Strip height in px. Default 8. */
10
64
  stripHeight?: number
65
+ /** The row the user has chosen — a persistent wash, distinct from transient hover. */
66
+ isSelected?: boolean
67
+ /** Root style, for the chrome a card wraps the row in (superset radius, margins). */
68
+ style?: StyleProp<ViewStyle>
11
69
  /** Root testID. Default "exercise-card"; override when nested inside another card. */
12
70
  testID?: string
13
71
  }
14
72
 
15
73
  /**
16
- * The complete, standalone session-rail heading: an {@link ExerciseHeading} info
17
- * block over its per-set {@link SetStrip}. The unit the rail lists — self-contained
18
- * and independent of {@link ExerciseCard} (whose `rail` state delegates here).
74
+ * Hover and press state for the whole row. RNW ends a wrapper's hover the moment a
75
+ * nested Pressable claims the pointer (see `useHoverState`), so the row listens on
76
+ * both itself and its heading and takes either.
77
+ */
78
+ function useRowInteraction() {
79
+ const { hovered: rowHovered, hoverProps } = useHoverState()
80
+ const [headingHovered, setHeadingHovered] = useState(false)
81
+ const [pressed, setPressed] = useState(false)
82
+
83
+ return {
84
+ hoverProps,
85
+ pressed,
86
+ hovered: rowHovered || headingHovered,
87
+ headingHandlers: {
88
+ onHoverIn: () => setHeadingHovered(true),
89
+ onHoverOut: () => setHeadingHovered(false),
90
+ onPressIn: () => setPressed(true),
91
+ onPressOut: () => setPressed(false),
92
+ },
93
+ }
94
+ }
95
+
96
+ /** Precedence: a press beats a selection, a selection beats a hover. */
97
+ function rowWash(
98
+ mode: ThemeMode,
99
+ state: { pressed: boolean; isSelected: boolean; hovered: boolean }
100
+ ): string | undefined {
101
+ if (state.pressed) return exerciseRowStateColor('pressed', mode)
102
+ if (state.isSelected) return exerciseRowStateColor('selected', mode)
103
+ if (state.hovered) return exerciseRowStateColor('hovered', mode)
104
+ return undefined
105
+ }
106
+
107
+ /** Narrow the prescription union back to the exact member the caller supplied. */
108
+ function prescriptionProps(p: ExerciseCardHeadingProps): ExerciseHeadingProps {
109
+ const base = { name: p.name }
110
+ if (p.prescription !== undefined) return { ...base, prescription: p.prescription }
111
+ if (p.sets === undefined) return base
112
+ return { ...base, sets: p.sets, reps: p.reps, load: p.load }
113
+ }
114
+
115
+ /**
116
+ * The exercise row — one component for all three densities the workout surfaces
117
+ * list an exercise in: the standalone session-rail heading (`rail`), the collapsed
118
+ * card row (`compact`) and the not-yet-reached row (`upcoming`). An
119
+ * {@link ExerciseHeading} info block over its per-set {@link SetStrip}.
120
+ *
121
+ * Interaction states are washes from the `interactive-*` tokens (press > selection >
122
+ * hover) plus a `isLive` name tone. All are static: this row renders on a wall display
123
+ * during a set, where titan's "the grain never animates" rule applies.
124
+ *
19
125
  * e1RM is intentionally dropped (a planning / live-panel concern). Dimming lives on
20
126
  * the outer wrapper so the strip dims with the heading.
21
127
  */
22
- export function ExerciseCardHeading({
23
- setStates,
24
- stripHeight = 8,
25
- dimmed,
26
- testID = 'exercise-card',
27
- ...heading
28
- }: ExerciseCardHeadingProps) {
128
+ export function ExerciseCardHeading(props: ExerciseCardHeadingProps) {
129
+ const {
130
+ density = 'rail',
131
+ setStates = [],
132
+ stripHeight = 8,
133
+ dimmed,
134
+ isSelected = false,
135
+ isLive = false,
136
+ testID = 'exercise-card',
137
+ style,
138
+ } = props
139
+ const spec = DENSITY[density]
140
+ const { hoverProps, pressed, hovered, headingHandlers } = useRowInteraction()
141
+ const wash = rowWash(useSurfaceMode(), { pressed, isSelected, hovered })
142
+ // The inline densities have no TempoDisplay sibling, so the whole padded row can be
143
+ // the press target — the touch target the collapsed card has always had. `rail` keeps
144
+ // the name row as the button because nesting TempoDisplay inside one is illegal.
145
+ const rowIsButton = spec.layout === 'inline'
146
+
147
+ const Root = rowIsButton ? Pressable : View
148
+ const rootPress = rowIsButton
149
+ ? {
150
+ ...headingHandlers,
151
+ onPress: props.onPress,
152
+ accessibilityRole: 'button' as const,
153
+ accessibilityLabel: exerciseHeadingLabel(props),
154
+ }
155
+ : {}
156
+
29
157
  return (
30
- <View
31
- style={{ paddingVertical: 9, paddingHorizontal: 12, opacity: dimmed ? 0.55 : 1 }}
158
+ <Root
159
+ {...hoverProps}
160
+ {...rootPress}
161
+ style={[
162
+ {
163
+ paddingVertical: spec.paddingVertical,
164
+ paddingHorizontal: spec.paddingHorizontal,
165
+ opacity: (dimmed ?? spec.dimByDefault) ? spec.dimOpacity : 1,
166
+ },
167
+ wash ? { backgroundColor: wash } : null,
168
+ style,
169
+ ]}
32
170
  testID={testID}
33
171
  >
34
- <ExerciseHeading {...heading} />
172
+ <ExerciseHeading
173
+ {...prescriptionProps(props)}
174
+ {...(rowIsButton ? { pressTarget: 'ancestor' as const } : headingHandlers)}
175
+ unit={props.unit}
176
+ tempo={props.tempo}
177
+ indicator={props.indicator}
178
+ previousBest={props.previousBest}
179
+ onPress={rowIsButton ? undefined : props.onPress}
180
+ layout={spec.layout}
181
+ mutedPrescription={spec.mutedPrescription}
182
+ isLive={isLive}
183
+ />
35
184
 
36
185
  {setStates.length > 0 && (
37
186
  <View style={{ marginTop: 7 }} testID="exercise-card-strip">
38
187
  <SetStrip sets={setStates} height={stripHeight} />
39
188
  </View>
40
189
  )}
41
- </View>
190
+ </Root>
42
191
  )
43
192
  }
@@ -24,6 +24,7 @@ import {
24
24
  import { resolveColor } from '../../../theme/resolve-color'
25
25
  import { getSemanticColors } from '../../../theme/tokens/semantic'
26
26
  import { cn } from '../../../utils/cn'
27
+ import { alpha } from '../../../utils/colors'
27
28
 
28
29
  const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
29
30
 
@@ -545,9 +546,9 @@ export function ExerciseDetailPage({
545
546
  paddingVertical: 4,
546
547
  paddingHorizontal: 10,
547
548
  borderRadius: 6,
548
- backgroundColor: 'rgba(255,121,0,0.10)',
549
+ backgroundColor: alpha(BRAND_PRIMARY, 0.1),
549
550
  borderWidth: 1,
550
- borderColor: 'rgba(255,121,0,0.25)',
551
+ borderColor: alpha(BRAND_PRIMARY, 0.25),
551
552
  }}
552
553
  testID="exercise-detail-page-e1rm"
553
554
  >