@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
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, within } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import { ExerciseCard } from './ExerciseCard'
5
5
  import type { SetRowProps } from './SetRow'
6
+ import { exerciseRowStateColor } from './exerciseRowState'
6
7
  import { resolveColor } from '../../../theme/resolve-color'
7
8
 
8
9
  const baseCollapsedProps = {
@@ -40,17 +41,21 @@ describe('ExerciseCard', () => {
40
41
  expect(screen.getByTestId('exercise-card-name')).toHaveTextContent('Bench Press')
41
42
  })
42
43
 
43
- it('renders summary text', () => {
44
+ it('renders the prescription through the shared SetsRepsLoad line', () => {
44
45
  render(<ExerciseCard {...baseCollapsedProps} />)
45
- expect(screen.getByTestId('exercise-card-summary')).toHaveTextContent('3×6 @ 175 lbs')
46
+ const summary = screen.getByTestId('exercise-card-summary')
47
+ expect(summary).toHaveTextContent('3')
48
+ expect(summary).toHaveTextContent('6')
49
+ expect(summary).toHaveTextContent('175')
50
+ expect(summary).toHaveTextContent('lbs')
46
51
  })
47
52
 
48
- it('renders PrBadge when isPR is true', () => {
53
+ it('surfaces a PR through the indicator chip, as the expanded card already did', () => {
49
54
  render(<ExerciseCard {...baseCollapsedProps} isPR />)
50
- expect(screen.getByTestId('pr-badge-star')).toBeInTheDocument()
55
+ expect(screen.getByLabelText('Personal record')).toBeInTheDocument()
51
56
  })
52
57
 
53
- it('renders compact velocity strips for logged sets', () => {
58
+ it('renders logged and remaining sets as one SetStrip', () => {
54
59
  render(
55
60
  <ExerciseCard
56
61
  {...baseCollapsedProps}
@@ -61,21 +66,22 @@ describe('ExerciseCard', () => {
61
66
  totalPlannedSets={4}
62
67
  />
63
68
  )
64
- expect(screen.getByTestId('exercise-card-velocity-strip-0')).toBeInTheDocument()
65
- expect(screen.getByTestId('exercise-card-velocity-strip-1')).toBeInTheDocument()
66
- })
67
-
68
- it('renders placeholder strips for remaining planned sets', () => {
69
- render(
70
- <ExerciseCard {...baseCollapsedProps} setVelocities={[[1.1, 0.95]]} totalPlannedSets={3} />
69
+ expect(screen.getByTestId('exercise-card-strip')).toBeInTheDocument()
70
+ expect(screen.getByTestId('set-strip')).toHaveAccessibleName(
71
+ 'Set progress: 2 done, 0 in progress, 2 upcoming'
71
72
  )
72
- expect(screen.getByTestId('exercise-card-placeholder-0')).toBeInTheDocument()
73
- expect(screen.getByTestId('exercise-card-placeholder-1')).toBeInTheDocument()
74
73
  })
75
74
 
76
- it('does not render strips container when no velocities and no planned sets', () => {
75
+ it('does not render the strip when there are no velocities and no planned sets', () => {
77
76
  render(<ExerciseCard {...baseCollapsedProps} />)
78
- expect(screen.queryByTestId('exercise-card-strips')).not.toBeInTheDocument()
77
+ expect(screen.queryByTestId('exercise-card-strip')).not.toBeInTheDocument()
78
+ })
79
+
80
+ it('washes the row when selected', () => {
81
+ render(<ExerciseCard {...baseCollapsedProps} isSelected />)
82
+ expect(screen.getByTestId('exercise-card')).toHaveStyle({
83
+ backgroundColor: exerciseRowStateColor('selected'),
84
+ })
79
85
  })
80
86
  })
81
87
 
@@ -183,6 +189,18 @@ describe('ExerciseCard', () => {
183
189
  render(<ExerciseCard {...expandedProps} />)
184
190
  expect(screen.queryByTestId('tempo-display')).not.toBeInTheDocument()
185
191
  })
192
+
193
+ // VW-97 item 3: ExerciseCardHeading takes `indicator`, but ExerciseCard didn't forward one —
194
+ // so the expanded rail row lost its indicator badge. Already fixed on main; this pins it.
195
+ it('forwards `indicator` to the header, so the expanded rail row keeps its badge', () => {
196
+ render(<ExerciseCard {...expandedProps} indicator="info" />)
197
+ expect(screen.getByTestId('exercise-indicator')).toBeInTheDocument()
198
+ })
199
+
200
+ it('falls back to the `pr` indicator from `isPR` when no explicit indicator is given', () => {
201
+ render(<ExerciseCard {...expandedProps} isPR />)
202
+ expect(screen.getByTestId('exercise-indicator')).toBeInTheDocument()
203
+ })
186
204
  })
187
205
 
188
206
  describe('upcoming', () => {
@@ -267,10 +285,12 @@ describe('ExerciseCard', () => {
267
285
  })
268
286
  })
269
287
 
288
+ // The row IS the card now, so the superset chrome lands on the row root rather
289
+ // than on a Pressable's inner View.
270
290
  describe('superset position border radius', () => {
271
291
  it('applies first position border radius', () => {
272
292
  render(<ExerciseCard {...baseCollapsedProps} supersetPosition="first" />)
273
- const container = screen.getByTestId('exercise-card').firstChild as HTMLElement
293
+ const container = screen.getByTestId('exercise-card')
274
294
  expect(container).toHaveStyle({
275
295
  borderTopLeftRadius: '12px',
276
296
  borderTopRightRadius: '12px',
@@ -281,7 +301,7 @@ describe('ExerciseCard', () => {
281
301
 
282
302
  it('applies last position border radius', () => {
283
303
  render(<ExerciseCard {...baseCollapsedProps} supersetPosition="last" />)
284
- const container = screen.getByTestId('exercise-card').firstChild as HTMLElement
304
+ const container = screen.getByTestId('exercise-card')
285
305
  expect(container).toHaveStyle({
286
306
  borderTopLeftRadius: '8px',
287
307
  borderTopRightRadius: '8px',
@@ -292,7 +312,7 @@ describe('ExerciseCard', () => {
292
312
 
293
313
  it('applies middle position border radius', () => {
294
314
  render(<ExerciseCard {...baseCollapsedProps} supersetPosition="middle" />)
295
- const container = screen.getByTestId('exercise-card').firstChild as HTMLElement
315
+ const container = screen.getByTestId('exercise-card')
296
316
  expect(container).toHaveStyle({
297
317
  borderTopLeftRadius: '8px',
298
318
  borderTopRightRadius: '8px',
@@ -1,16 +1,12 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useState } from 'react'
3
- import { View, Pressable } from 'react-native'
4
- import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
5
- import { PlaceholderStrip } from './PlaceholderStrip'
6
- import { PrBadge } from './PrBadge'
3
+ import { View } from 'react-native'
4
+ import { type VelocityZoneBandProp } from './VelocityStrip'
7
5
  import { SetRow, type SetRowProps } from './SetRow'
8
6
  import { type SetStripSet } from './SetStrip'
9
7
  import { SetTableHeader } from './SetTableHeader'
10
8
  import { ExerciseCardHeading } from './ExerciseCardHeading'
11
9
  import { type ExerciseIndicatorKind } from './ExerciseIndicator'
12
- import { Typography } from '../Typography'
13
- import { roundWeight } from '../../../utils/workout-format'
14
10
  import { resolveColor } from '../../../theme/resolve-color'
15
11
 
16
12
  export interface ExerciseCardProps {
@@ -34,12 +30,19 @@ export interface ExerciseCardProps {
34
30
  unit: 'lbs' | 'kg'
35
31
  }
36
32
  isPR?: boolean
37
- /** Collapsed glance: per-set logged velocities (drives the mini strips). */
33
+ /**
34
+ * Collapsed glance: per-set logged velocities. Projected onto `setStates` for the
35
+ * row's `SetStrip` — pass `setStates` directly for anything richer than done/todo.
36
+ */
38
37
  setVelocities?: number[][]
39
- /** Optional velocity-zone bands shared across this exercise's sets (WA bands). */
38
+ /** Velocity-zone bands shared across this exercise's sets (WA bands). Expanded body only. */
40
39
  velocityZones?: readonly VelocityZoneBandProp[]
41
- /** Collapsed glance: total planned sets (placeholder strips fill the remainder). */
40
+ /** Collapsed glance: total planned sets (todo bars fill the remainder). */
42
41
  totalPlannedSets?: number
42
+ /** The row the user has chosen — a persistent wash on the collapsed / upcoming row. */
43
+ isSelected?: boolean
44
+ /** This exercise is being performed right now — the name takes the live tone. */
45
+ isLive?: boolean
43
46
  /** Expanded body: the per-set rows (done / live / todo). */
44
47
  sets?: SetRowProps[]
45
48
  tempo?: [number, number, number, number]
@@ -47,11 +50,14 @@ export interface ExerciseCardProps {
47
50
  previousBest?: string
48
51
  supersetPosition?: 'first' | 'last' | 'middle' | null
49
52
  supersetColor?: string
50
- /** Expanded header per-set strip override; derived from `sets` when omitted. */
53
+ /**
54
+ * Per-set strip override for the collapsed and expanded rows; derived from
55
+ * `setVelocities` (collapsed) or `sets` (expanded) when omitted.
56
+ */
51
57
  setStates?: SetStripSet[]
52
- /** A small PR / issue / info chip in the title line (expanded header). */
58
+ /** A small PR / issue / info chip in the title line. */
53
59
  indicator?: ExerciseIndicatorKind
54
- /** Expanded header strip height in px. Default 8. */
60
+ /** Row strip height in px. Default 8. */
55
61
  stripHeight?: number
56
62
  }
57
63
 
@@ -105,19 +111,28 @@ function getSupersetMargin(
105
111
  return {}
106
112
  }
107
113
 
108
- function formatSummary(summary: ExerciseCardProps['summary']): string {
109
- if (!summary) return ''
110
- return `${summary.sets}×${summary.reps} @ ${roundWeight(summary.weight)} ${summary.unit}`
114
+ /** The chrome a card wraps its row in: superset corner radii + the inter-card margin. */
115
+ function supersetChrome(position: ExerciseCardProps['supersetPosition']): Record<string, number> {
116
+ return { ...getSupersetBorderRadius(position), ...getSupersetMargin(position) }
111
117
  }
112
118
 
113
- function formatAccessibilityLabel(
114
- name: string,
115
- summary: ExerciseCardProps['summary'],
116
- prescription: ExerciseCardProps['prescription']
117
- ): string {
118
- if (summary) return `${name}, ${formatSummary(summary)}`
119
- if (prescription) return `${name}, ${prescription}`
120
- return name
119
+ /** Project the collapsed glance's velocities + planned total onto the strip's per-set states. */
120
+ function deriveCollapsedSetStates(
121
+ setVelocities: number[][] | undefined,
122
+ totalPlannedSets: number | undefined,
123
+ plannedReps: number
124
+ ): SetStripSet[] {
125
+ const done = (setVelocities ?? []).map(
126
+ (velocities): SetStripSet => ({ status: 'done', velocities })
127
+ )
128
+ const remaining = Math.max(0, (totalPlannedSets ?? 0) - done.length)
129
+ return [
130
+ ...done,
131
+ ...Array.from(
132
+ { length: remaining },
133
+ (): SetStripSet => ({ status: 'todo', planned: plannedReps })
134
+ ),
135
+ ]
121
136
  }
122
137
 
123
138
  function CollapsedCard({
@@ -125,90 +140,40 @@ function CollapsedCard({
125
140
  onToggle,
126
141
  summary,
127
142
  isPR,
143
+ indicator,
128
144
  setVelocities,
129
- velocityZones,
145
+ setStates,
130
146
  totalPlannedSets,
147
+ stripHeight,
131
148
  supersetPosition,
149
+ isSelected,
150
+ isLive,
132
151
  }: CardBodyProps) {
133
- const [pressed, setPressed] = useState(false)
134
- const completedSets = setVelocities?.length ?? 0
135
- const remaining = Math.max(0, (totalPlannedSets ?? 0) - completedSets)
136
- const borderRadius = getSupersetBorderRadius(supersetPosition)
137
- const supersetMargin = getSupersetMargin(supersetPosition)
138
-
139
- return (
140
- <Pressable
141
- onPress={onToggle}
142
- onPressIn={() => setPressed(true)}
143
- onPressOut={() => setPressed(false)}
144
- accessibilityRole="button"
145
- accessibilityLabel={formatAccessibilityLabel(name, summary, undefined)}
146
- testID="exercise-card"
147
- >
148
- <View
149
- className={pressed ? 'bg-surface-raised' : undefined}
150
- style={{
151
- padding: 12,
152
- paddingHorizontal: 14,
153
- cursor: 'pointer',
154
- ...borderRadius,
155
- ...supersetMargin,
156
- }}
157
- >
158
- <View className="flex-row items-center" testID="exercise-card-header">
159
- {/* 14px/700 Space Grotesk is `h6` at the `sm` step; the face and the heading
160
- role come from the variant, the weight and size from the scale. */}
161
- <Typography
162
- variant="h6"
163
- color="primary"
164
- className="text-sm font-bold leading-[normal]"
165
- testID="exercise-card-name"
166
- >
167
- {name}
168
- </Typography>
169
- <View className="flex-1" />
170
- {summary && (
171
- /* The demo's "body" face is Inter, which is the library's `font-sans`
172
- (the mapping B1 established for PrBadge), not Typography's `font-body`. */
173
- <Typography
174
- variant="caption"
175
- color="secondary"
176
- className="font-sans mr-2 leading-[normal]"
177
- testID="exercise-card-summary"
178
- >
179
- {formatSummary(summary)}
180
- </Typography>
181
- )}
182
- {isPR && (
183
- <View style={{ marginLeft: 4 }}>
184
- <PrBadge type="e1rm" compact animate={false} />
185
- </View>
186
- )}
187
- </View>
152
+ const plannedReps = typeof summary?.reps === 'number' ? summary.reps : 0
153
+ const row = {
154
+ density: 'compact' as const,
155
+ name,
156
+ indicator: indicator ?? (isPR ? ('pr' as const) : undefined),
157
+ setStates: setStates ?? deriveCollapsedSetStates(setVelocities, totalPlannedSets, plannedReps),
158
+ stripHeight,
159
+ isSelected,
160
+ isLive,
161
+ onPress: onToggle,
162
+ style: supersetChrome(supersetPosition),
163
+ }
188
164
 
189
- {(completedSets > 0 || remaining > 0) && (
190
- <View className="flex-row" style={{ marginTop: 6, gap: 4 }} testID="exercise-card-strips">
191
- {setVelocities?.map((velocities, i) => (
192
- <VelocityStrip
193
- key={i}
194
- velocities={velocities}
195
- zones={velocityZones}
196
- variant="compact"
197
- height={8}
198
- hideBaseline
199
- testID={`exercise-card-velocity-strip-${i}`}
200
- />
201
- ))}
202
- {Array.from({ length: remaining }, (_, i) => (
203
- <PlaceholderStrip
204
- key={`placeholder-${i}`}
205
- testID={`exercise-card-placeholder-${i}`}
206
- />
207
- ))}
208
- </View>
209
- )}
210
- </View>
211
- </Pressable>
165
+ // Two call shapes, not a conditional spread: the prescription union only narrows
166
+ // when every field of a member is present at the call site.
167
+ return summary ? (
168
+ <ExerciseCardHeading
169
+ {...row}
170
+ sets={summary.sets}
171
+ reps={summary.reps}
172
+ load={summary.weight}
173
+ unit={summary.unit}
174
+ />
175
+ ) : (
176
+ <ExerciseCardHeading {...row} />
212
177
  )
213
178
  }
214
179
 
@@ -243,9 +208,8 @@ function ExpandedCard({
243
208
  stripHeight = 8,
244
209
  velocityZones,
245
210
  supersetPosition,
211
+ isLive,
246
212
  }: CardBodyProps) {
247
- const borderRadius = getSupersetBorderRadius(supersetPosition)
248
- const supersetMargin = getSupersetMargin(supersetPosition)
249
213
  // Summary is the card-level authority for the weight column; fall back to the
250
214
  // first set's unit, then lbs. (Mixed per-set units keep the card-level label.)
251
215
  const unit = summary?.unit ?? sets?.[0]?.unit ?? 'lbs'
@@ -258,11 +222,7 @@ function ExpandedCard({
258
222
  return (
259
223
  <View
260
224
  className="bg-surface-elevated border-hairline"
261
- style={{
262
- borderWidth: 1,
263
- ...borderRadius,
264
- ...supersetMargin,
265
- }}
225
+ style={{ borderWidth: 1, ...supersetChrome(supersetPosition) }}
266
226
  testID="exercise-card"
267
227
  >
268
228
  <ExerciseCardHeading
@@ -275,6 +235,7 @@ function ExpandedCard({
275
235
  indicator={headerIndicator}
276
236
  setStates={headerStates}
277
237
  stripHeight={stripHeight}
238
+ isLive={isLive}
278
239
  onPress={onToggle}
279
240
  testID="exercise-card-heading"
280
241
  />
@@ -306,72 +267,36 @@ function UpcomingCard({
306
267
  name,
307
268
  prescription,
308
269
  previousBest,
270
+ indicator,
309
271
  supersetPosition,
272
+ isSelected,
310
273
  onToggle,
311
274
  }: CardBodyProps) {
312
- const borderRadius = getSupersetBorderRadius(supersetPosition)
313
- const supersetMargin = getSupersetMargin(supersetPosition)
275
+ const row = {
276
+ density: 'upcoming' as const,
277
+ name,
278
+ previousBest,
279
+ indicator,
280
+ isSelected,
281
+ onPress: onToggle,
282
+ style: supersetChrome(supersetPosition),
283
+ }
314
284
 
315
- return (
316
- <Pressable onPress={onToggle} accessibilityRole="button">
317
- <View
318
- style={{
319
- opacity: 0.6,
320
- padding: 12,
321
- paddingHorizontal: 14,
322
- ...borderRadius,
323
- ...supersetMargin,
324
- }}
325
- accessibilityLabel={formatAccessibilityLabel(name, undefined, prescription)}
326
- testID="exercise-card"
327
- >
328
- <View className="flex-row items-center">
329
- {/* Name never truncates (no numberOfLines); prescription + previousBest ellipsize first. */}
330
- <Typography
331
- variant="h6"
332
- color="primary"
333
- className="shrink-0 text-sm font-bold leading-[normal]"
334
- testID="exercise-card-name"
335
- >
336
- {name}
337
- </Typography>
338
- {prescription && (
339
- <Typography
340
- variant="caption"
341
- color="secondary"
342
- maxLines={1}
343
- className="font-sans shrink ml-2 leading-[normal]"
344
- testID="exercise-card-prescription"
345
- >
346
- {prescription}
347
- </Typography>
348
- )}
349
- <View className="flex-1" style={{ minWidth: 8 }} />
350
- {previousBest && (
351
- /* 11px is off the type scale; `2xs` (10px) is the step below, as B2's
352
- WorkoutCard date took for the same size. */
353
- <Typography
354
- variant="caption"
355
- color="tertiary"
356
- maxLines={1}
357
- className="font-sans shrink text-2xs leading-[normal]"
358
- testID="exercise-card-previous-best"
359
- >
360
- {previousBest}
361
- </Typography>
362
- )}
363
- </View>
364
- </View>
365
- </Pressable>
285
+ return prescription !== undefined ? (
286
+ <ExerciseCardHeading {...row} prescription={prescription} />
287
+ ) : (
288
+ <ExerciseCardHeading {...row} />
366
289
  )
367
290
  }
368
291
 
369
292
  /**
370
- * The data-contract exercise card, in three representations:
371
- * - `upcoming` a dimmed, not-yet-reached row (prescription + previous best).
372
- * - collapsed — a glance: name + summary + per-set velocity strips.
373
- * - expandedthe unified card: the real {@link ExerciseCardHeading} header over
374
- * the SET · REPS · LBS · RPE body (one {@link SetRow} per set).
293
+ * The data-contract exercise card, in three representations — all three now ONE
294
+ * {@link ExerciseCardHeading}, selected by its `density`, rather than three hand-rolled
295
+ * heads:
296
+ * - `upcoming`a dimmed, not-yet-reached row (`density="upcoming"`).
297
+ * - collapsed a glance: name + prescription + the per-set strip (`density="compact"`).
298
+ * - expanded — the `rail` heading over the SET · REPS · LBS · RPE body (one
299
+ * {@link SetRow} per set).
375
300
  *
376
301
  * Expand is controlled (`expanded` + `onExpandedChange`) or uncontrolled
377
302
  * (`defaultExpanded`, internal state). `upcoming` overrides expand.
@@ -4,10 +4,10 @@ import type { SetStripSet } from './SetStrip'
4
4
  import { Surface } from '../../ui/surface'
5
5
 
6
6
  /**
7
- * `ExerciseCardHeading` — the complete, standalone session-rail heading: an
8
- * `ExerciseHeading` info block over its per-set `SetStrip`. This is the unit the rail
9
- * lists; it is independent of `ExerciseCard` (whose `state="rail"` delegates here).
10
- * An empty `setStates` renders no strip.
7
+ * `ExerciseCardHeading` — THE exercise row. One component for the three densities the
8
+ * workout surfaces list an exercise in: the session-rail heading (`rail`), the collapsed
9
+ * card row (`compact`) and the not-yet-reached row (`upcoming`). An `ExerciseHeading` info
10
+ * block over its per-set `SetStrip`; an empty `setStates` renders no strip.
11
11
  */
12
12
  const meta: Meta<typeof ExerciseCardHeading> = {
13
13
  title: 'Custom/Workout/ExerciseCardHeading',
@@ -17,15 +17,22 @@ const meta: Meta<typeof ExerciseCardHeading> = {
17
17
  docs: {
18
18
  description: {
19
19
  component:
20
- '**Molecule.** The complete standalone session-rail heading. Composes ' +
20
+ '**Molecule.** The exercise row, in three densities driven by props rather than by ' +
21
+ 'three call sites (TD-03.56). Composes ' +
21
22
  '[ExerciseHeading](?path=/docs/custom-workout-exerciseheading--docs) + ' +
22
23
  '[SetStrip](?path=/docs/custom-workout-setstrip--docs). ' +
24
+ 'Interaction states (TD-03.55) are `interactive-*` token washes — press > selection > ' +
25
+ 'hover — plus an `isLive` name tone. All static: this row renders on the wall during a ' +
26
+ 'set, so nothing here animates. Hover is web-only; on a touch surface the row simply ' +
27
+ 'has no hover state and selection carries the same meaning. ' +
23
28
  'Used-by ↑ [SessionRail](?path=/docs/shell-sessionrail--docs); ' +
24
- '[ExerciseCard](?path=/docs/custom-workout-exercisecard--docs) `state="rail"` delegates here.',
29
+ '[ExerciseCard](?path=/docs/custom-workout-exercisecard--docs) delegates all three of its ' +
30
+ 'representations here.',
25
31
  },
26
32
  },
27
33
  },
28
34
  argTypes: {
35
+ density: { control: 'inline-radio', options: ['rail', 'compact', 'upcoming'] },
29
36
  unit: { control: 'select', options: ['lbs', 'kg'] },
30
37
  indicator: {
31
38
  control: 'select',
@@ -33,6 +40,8 @@ const meta: Meta<typeof ExerciseCardHeading> = {
33
40
  },
34
41
  stripHeight: { control: { type: 'range', min: 2, max: 16, step: 1 } },
35
42
  dimmed: { control: 'boolean' },
43
+ isSelected: { control: 'boolean' },
44
+ isLive: { control: 'boolean' },
36
45
  onPress: { action: 'press' },
37
46
  },
38
47
  decorators: [
@@ -68,6 +77,80 @@ export const Default: Story = {
68
77
  setStates: inProgress,
69
78
  stripHeight: 8,
70
79
  },
80
+ parameters: {
81
+ docs: {
82
+ description: {
83
+ story: 'The `rail` density: two lines (name, then prescription beside the tempo).',
84
+ },
85
+ },
86
+ },
87
+ }
88
+
89
+ export const Compact: Story = {
90
+ args: {
91
+ ...Default.args,
92
+ density: 'compact',
93
+ },
94
+ parameters: {
95
+ docs: {
96
+ description: {
97
+ story:
98
+ 'The collapsed card row: name and prescription on ONE line, strip below. The tempo is ' +
99
+ 'dropped — a single line has no room for a second metric lockup.',
100
+ },
101
+ },
102
+ },
103
+ }
104
+
105
+ export const Upcoming: Story = {
106
+ args: {
107
+ name: 'Overhead Press',
108
+ density: 'upcoming',
109
+ prescription: '3×8-12 @ RPE 8',
110
+ previousBest: '135 lbs × 10',
111
+ },
112
+ parameters: {
113
+ docs: {
114
+ description: {
115
+ story:
116
+ 'A not-yet-reached exercise: `compact`, dimmed by its own density, with the previous ' +
117
+ 'best pinned right. `prescription` is the free-text alternative to the structured ' +
118
+ '`sets` / `reps` / `load` triple, for an exercise whose numbers are not loaded yet.',
119
+ },
120
+ },
121
+ },
122
+ }
123
+
124
+ export const Live: Story = {
125
+ args: {
126
+ ...Default.args,
127
+ isLive: true,
128
+ },
129
+ parameters: {
130
+ docs: {
131
+ description: {
132
+ story:
133
+ 'The exercise being performed right now — the name takes `status-live`. A tone change, ' +
134
+ 'never motion: this row is on a wall display during a set.',
135
+ },
136
+ },
137
+ },
138
+ }
139
+
140
+ export const Selected: Story = {
141
+ args: {
142
+ ...Default.args,
143
+ isSelected: true,
144
+ },
145
+ parameters: {
146
+ docs: {
147
+ description: {
148
+ story:
149
+ 'The row the user has chosen: a persistent `interactive-selected` wash. Hover the row ' +
150
+ 'to see the lighter `interactive-hover` wash it outranks.',
151
+ },
152
+ },
153
+ },
71
154
  }
72
155
 
73
156
  export const Dimmed: Story = {