@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.
- package/README.md +14 -0
- package/dist/bodymap.js +129 -76
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +129 -76
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +117 -26
- package/dist/index.d.ts +117 -26
- package/dist/index.js +677 -450
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +673 -451
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-_3Ajpiqu.d.ts → pages-CsjsUn0G.d.ts} +30 -13
- package/dist/{pages-CpW3OB3g.d.mts → pages-ZbT_pD_U.d.mts} +30 -13
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +676 -701
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +676 -701
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +31 -7
- package/dist/theme/index.d.ts +31 -7
- package/dist/theme/index.js +84 -20
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +83 -21
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +84 -20
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +84 -20
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/dist/theme/tokens.d.mts +56 -7
- package/dist/theme/tokens.d.ts +56 -7
- package/dist/theme/tokens.js +72 -20
- package/dist/theme/tokens.js.map +1 -1
- package/dist/theme/tokens.mjs +71 -21
- package/dist/theme/tokens.mjs.map +1 -1
- package/docs/render-testing-pattern.md +130 -0
- package/package.json +1 -1
- package/src/arch/Architecture.stories.tsx +3 -1
- package/src/arch/arch-graph.json +74 -72
- package/src/components/custom/ActiveWork/InitiativeBrief.tsx +2 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +8 -3
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +4 -1
- package/src/components/custom/Gauge/Gauge.tsx +3 -1
- package/src/components/custom/Scatter/Scatter.stories.tsx +10 -7
- package/src/components/custom/Scatter/Scatter.tsx +6 -4
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
- package/src/components/custom/Table/Table.stories.tsx +1 -1
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Treemap/Treemap.stories.tsx +13 -10
- package/src/components/custom/Treemap/Treemap.tsx +4 -1
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +2 -1
- package/src/components/custom/Workout/BaseBadge.stories.tsx +11 -7
- package/src/components/custom/Workout/BaseBadge.tsx +3 -2
- package/src/components/custom/Workout/BodyMap.tsx +5 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +13 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +5 -3
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +2 -1
- package/src/components/custom/Workout/CapacityBandChart.tsx +5 -3
- package/src/components/custom/Workout/DeviationBar.test.tsx +5 -2
- package/src/components/custom/Workout/DeviationBar.tsx +5 -2
- package/src/components/custom/Workout/DualVelocityStrip.stories.tsx +7 -6
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +29 -16
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +8 -8
- package/src/components/custom/Workout/ExerciseCard.test.tsx +39 -19
- package/src/components/custom/Workout/ExerciseCard.tsx +92 -167
- package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +89 -6
- package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +189 -1
- package/src/components/custom/Workout/ExerciseCardHeading.tsx +167 -18
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +3 -2
- package/src/components/custom/Workout/ExerciseHeading.tsx +192 -47
- package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +6 -5
- package/src/components/custom/Workout/InputBar.tsx +4 -2
- package/src/components/custom/Workout/IntensityBar.tsx +4 -1
- package/src/components/custom/Workout/MesoCard.tsx +2 -1
- package/src/components/custom/Workout/MesoStatusCard.tsx +11 -10
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +8 -3
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +12 -2
- package/src/components/custom/Workout/README.md +58 -12
- package/src/components/custom/Workout/ReadinessCheck.tsx +4 -2
- package/src/components/custom/Workout/RestTimer.stories.tsx +13 -2
- package/src/components/custom/Workout/RestTimer.tsx +4 -2
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +16 -12
- package/src/components/custom/Workout/SegmentedBar.tsx +4 -3
- package/src/components/custom/Workout/SetStrip.stories.tsx +3 -2
- package/src/components/custom/Workout/SetsRepsLoad.tsx +23 -9
- package/src/components/custom/Workout/Sparkline.stories.tsx +13 -10
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +2 -1
- package/src/components/custom/Workout/StrengthTrendChart.tsx +8 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +4 -3
- package/src/components/custom/Workout/TempoDisplay.tsx +2 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +3 -2
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +1 -1
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +40 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +48 -6
- package/src/components/custom/Workout/VelocityStrip.tsx +23 -14
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +3 -1
- package/src/components/custom/Workout/exerciseRowState.ts +29 -0
- package/src/components/custom/Workout/index.ts +12 -2
- package/src/components/custom/Workout/metricText.stories.tsx +9 -8
- package/src/components/custom/Workout/metricText.tsx +4 -2
- package/src/components/custom/Workout/setHeadingKit.tsx +12 -10
- package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
- package/src/components/custom/charts/README.md +5 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +16 -0
- package/src/components/custom/charts/SetBarChart.tsx +14 -5
- package/src/components/custom/charts/flatBarGeometry.test.ts +41 -0
- package/src/components/custom/charts/flatBarGeometry.ts +32 -0
- package/src/components/custom/stepper/Stepper.tsx +8 -6
- package/src/components/ui/alert/Alert.stories.tsx +14 -4
- package/src/components/ui/alert/Alert.tsx +24 -3
- package/src/components/ui/button/Button.tsx +12 -12
- package/src/components/ui/checkbox/Checkbox.tsx +2 -2
- package/src/components/ui/data-row/DataRow.stories.tsx +6 -2
- package/src/components/ui/drawer/Drawer.tsx +4 -1
- package/src/components/ui/icon-box/IconBox.tsx +8 -6
- package/src/components/ui/indicator/Indicator.stories.tsx +4 -3
- package/src/components/ui/list-item/ListItem.stories.tsx +7 -4
- package/src/components/ui/modal/Modal.tsx +2 -0
- package/src/components/ui/pill/Pill.stories.tsx +53 -0
- package/src/components/ui/pill/Pill.tsx +10 -6
- package/src/components/ui/popover/Popover.stories.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +9 -6
- package/src/components/ui/radio/Radio.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +10 -10
- package/src/components/ui/select/Select.tsx +2 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -1
- package/src/components/ui/stack/Stack.stories.tsx +7 -7
- package/src/components/ui/surface/Surface.stories.tsx +75 -28
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +4 -2
- package/src/components/ui/toast/Toast.tsx +6 -4
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +13 -6
- package/src/stories/PresetShowcase.stories.tsx +1 -1
- package/src/test/no-raw-color.test.ts +39 -0
- package/src/theme/Alpha.stories.tsx +103 -0
- package/src/theme/ProposedTokensVW82.stories.tsx +414 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/global.css +15 -3
- package/src/theme/subtle-tone-weight.test.ts +163 -0
- package/src/theme/tailwind-var-opacity.test.ts +163 -0
- package/src/theme/tokens/primitives.ts +33 -0
- package/src/theme/tokens/semantic-hex-guard.test.ts +322 -0
- package/src/theme/tokens/semantic.ts +56 -20
- package/src/utils/colors.alpha-adoption.test.ts +408 -0
- 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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
<
|
|
31
|
-
|
|
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
|
|
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
|
-
</
|
|
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:
|
|
549
|
+
backgroundColor: alpha(BRAND_PRIMARY, 0.1),
|
|
549
550
|
borderWidth: 1,
|
|
550
|
-
borderColor:
|
|
551
|
+
borderColor: alpha(BRAND_PRIMARY, 0.25),
|
|
551
552
|
}}
|
|
552
553
|
testID="exercise-detail-page-e1rm"
|
|
553
554
|
>
|