@titan-design/react-ui 0.10.0 → 0.11.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 (84) hide show
  1. package/dist/index.d.mts +399 -4
  2. package/dist/index.d.ts +399 -4
  3. package/dist/index.js +2295 -895
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2279 -905
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  8. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +1974 -1740
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +1975 -1741
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +8 -3
  16. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  17. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  18. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  19. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  20. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  21. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  22. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  23. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  24. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  25. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  26. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  27. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  28. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  29. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  30. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  31. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  32. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  33. package/src/components/custom/Fatigue/README.md +93 -0
  34. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  35. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  36. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  37. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  38. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  39. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  40. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  41. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  42. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  43. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  44. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  45. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  46. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  47. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  48. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  49. package/src/components/custom/Fatigue/index.ts +41 -0
  50. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  51. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  52. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  56. package/src/components/custom/Workout/README.md +62 -16
  57. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  58. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  59. package/src/components/custom/Workout/SessionRail.tsx +50 -13
  60. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  61. package/src/components/custom/Workout/SetRow.tsx +9 -2
  62. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  63. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  64. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  65. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  66. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  68. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  69. package/src/components/custom/Workout/index.ts +3 -0
  70. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  71. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  72. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  73. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  74. package/src/components/custom/index.ts +1 -0
  75. package/src/test/raw-color-patterns.test.ts +71 -0
  76. package/src/theme/ColorPalettes.stories.tsx +871 -0
  77. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  78. package/src/theme/bar-paper.ts +59 -0
  79. package/src/theme/color-stories.coverage.test.ts +130 -0
  80. package/src/theme/color-story-kit.tsx +170 -0
  81. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  82. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  83. package/src/theme/ColorSystem.stories.tsx +0 -508
  84. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,226 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * `Workout/Fatigue/Silver-Red Palette` — the single documented source for the fatigue
4
+ * family's line/quality colour language. SILVER when the rep is right, SHADES OF RED when
5
+ * something's wrong — no greens, no ambers (those belong to the verdict tones + velocity-
6
+ * loss bands, not here). Every swatch reads its value straight from `fatigue-tokens`, so
7
+ * this story can't drift from what the components actually render.
8
+ */
9
+ import type { Meta, StoryObj } from '@storybook/react-vite'
10
+ import { View, Text } from 'react-native'
11
+ import { primitiveColors } from '../../../theme/tokens/primitives'
12
+ import { alpha } from '../../../utils/colors'
13
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
14
+ import {
15
+ SILVER,
16
+ DRIFT_GREY,
17
+ RED_LIGHT,
18
+ RED_MID,
19
+ RED_DEEP,
20
+ PHASE_AXIS_COLOR,
21
+ FONT_HEAD,
22
+ FONT_UI,
23
+ FONT_MONO,
24
+ } from './fatigue-tokens'
25
+
26
+ const PAGE_BG = primitiveColors.charcoal[900]
27
+ const PANEL_BG = primitiveColors.charcoal[800]
28
+ const t = getSemanticColors('dark')
29
+
30
+ const meta: Meta = {
31
+ title: 'Foundations/Color/Silver-Red Scheme',
32
+ parameters: {
33
+ layout: 'fullscreen',
34
+ docs: {
35
+ description: {
36
+ component:
37
+ 'The locked silver→red colour language for the live-fatigue family. SILVER = the ' +
38
+ 'rep is controlled (dimming toward DRIFT_GREY with tempo drift); SHADES OF RED = a ' +
39
+ 'collapse, by severity. Plus the phase-axis tones (eccentric magenta / concentric ' +
40
+ 'cyan / idle grey) that colour the ghost band. Used by RomProgressionChart, ' +
41
+ 'GhostSpark, and the dual ghost-line. All values sourced from `fatigue-tokens`.',
42
+ },
43
+ },
44
+ },
45
+ }
46
+ export default meta
47
+ type Story = StoryObj
48
+
49
+ interface SwatchDef {
50
+ name: string
51
+ token: string
52
+ value: string
53
+ note?: string
54
+ }
55
+
56
+ function Swatch({ def }: { def: SwatchDef }) {
57
+ return (
58
+ <View style={{ width: 132, gap: 6 }}>
59
+ <View
60
+ style={{
61
+ height: 72,
62
+ borderRadius: 10,
63
+ backgroundColor: def.value,
64
+ borderWidth: 1,
65
+ borderColor: alpha(primitiveColors.white, 0.08),
66
+ }}
67
+ />
68
+ <View style={{ gap: 2 }}>
69
+ <Text
70
+ style={{
71
+ fontSize: 12,
72
+ fontWeight: '800',
73
+ fontFamily: FONT_HEAD,
74
+ color: t['text-primary'],
75
+ }}
76
+ >
77
+ {def.name}
78
+ </Text>
79
+ <Text style={{ fontSize: 10, fontFamily: FONT_MONO, color: t['text-tertiary'] }}>
80
+ {def.token}
81
+ </Text>
82
+ <Text style={{ fontSize: 11, fontFamily: FONT_MONO, color: t['text-secondary'] }}>
83
+ {def.value.toUpperCase()}
84
+ </Text>
85
+ {def.note && (
86
+ <Text
87
+ style={{ fontSize: 10, fontFamily: FONT_UI, color: t['text-tertiary'], lineHeight: 13 }}
88
+ >
89
+ {def.note}
90
+ </Text>
91
+ )}
92
+ </View>
93
+ </View>
94
+ )
95
+ }
96
+
97
+ function SwatchGroup({ label, defs }: { label: string; defs: SwatchDef[] }) {
98
+ return (
99
+ <View style={{ gap: 12 }}>
100
+ <Text
101
+ style={{
102
+ fontSize: 9,
103
+ letterSpacing: 1.4,
104
+ fontFamily: FONT_MONO,
105
+ color: t['text-tertiary'],
106
+ }}
107
+ >
108
+ {label}
109
+ </Text>
110
+ <View style={{ flexDirection: 'row', gap: 16, flexWrap: 'wrap' }}>
111
+ {defs.map((d) => (
112
+ <Swatch key={d.token} def={d} />
113
+ ))}
114
+ </View>
115
+ </View>
116
+ )
117
+ }
118
+
119
+ const CONTROL: SwatchDef[] = [
120
+ {
121
+ // eslint-disable-next-line titan/no-raw-color -- 'Silver' is the token's display name, not a CSS colour
122
+ name: 'Silver',
123
+ token: 'SILVER · neutral[300]',
124
+ value: SILVER,
125
+ note: 'On-track / at-or-above working.',
126
+ },
127
+ {
128
+ name: 'Drift grey',
129
+ token: 'DRIFT_GREY · neutral[600]',
130
+ value: DRIFT_GREY,
131
+ note: 'The dim-silver grey the controlled line dims toward as tempo drifts — same neutral family as SILVER, never a colour.',
132
+ },
133
+ ]
134
+
135
+ const COLLAPSE: SwatchDef[] = [
136
+ {
137
+ name: 'Red light',
138
+ token: 'RED_LIGHT · red[400]',
139
+ value: RED_LIGHT,
140
+ note: 'First flag — collapse just crossed the grind threshold.',
141
+ },
142
+ { name: 'Red mid', token: 'RED_MID · red[600]', value: RED_MID, note: 'Sustained collapse.' },
143
+ {
144
+ name: 'Red deep',
145
+ token: 'RED_DEEP · red[800]',
146
+ value: RED_DEEP,
147
+ note: 'Full collapse — form breaking down.',
148
+ },
149
+ ]
150
+
151
+ const PHASE_AXIS: SwatchDef[] = [
152
+ {
153
+ name: 'Eccentric',
154
+ token: 'PHASE_AXIS_COLOR.eccentric · magenta[800]',
155
+ value: PHASE_AXIS_COLOR.eccentric,
156
+ },
157
+ {
158
+ name: 'Concentric',
159
+ token: 'PHASE_AXIS_COLOR.concentric · cyan[800]',
160
+ value: PHASE_AXIS_COLOR.concentric,
161
+ },
162
+ {
163
+ name: 'Idle',
164
+ token: 'PHASE_AXIS_COLOR.idle · charcoal[300]',
165
+ value: PHASE_AXIS_COLOR.idle,
166
+ note: 'Pauses / holds on the phase band.',
167
+ },
168
+ ]
169
+
170
+ export const Palette: Story = {
171
+ name: 'Silver-Red Palette',
172
+ render: () => (
173
+ <View style={{ padding: 32, backgroundColor: PAGE_BG, minHeight: '100%', gap: 28 }}>
174
+ <View style={{ gap: 8, maxWidth: 720 }}>
175
+ <Text
176
+ style={{
177
+ fontSize: 9,
178
+ letterSpacing: 1.4,
179
+ fontFamily: FONT_MONO,
180
+ color: t['text-tertiary'],
181
+ }}
182
+ >
183
+ LIVE-FATIGUE · QUALITY COLOUR LANGUAGE
184
+ </Text>
185
+ <Text
186
+ style={{
187
+ fontSize: 24,
188
+ fontWeight: '900',
189
+ fontFamily: FONT_HEAD,
190
+ color: t['text-primary'],
191
+ }}
192
+ >
193
+ Silver → Red
194
+ </Text>
195
+ <Text
196
+ style={{ fontSize: 13, fontFamily: FONT_UI, color: t['text-secondary'], lineHeight: 19 }}
197
+ >
198
+ One language for every quality readout in the family: SILVER when the rep is right
199
+ (dimming toward DRIFT_GREY with tempo drift), SHADES OF RED when something&apos;s wrong —
200
+ by severity. No greens, no ambers; those belong to the verdict tones and velocity-loss
201
+ bands, not here. The phase-axis tones below colour the ghost band (they carry movement
202
+ phase, not quality).
203
+ </Text>
204
+ </View>
205
+
206
+ <View style={[{ borderRadius: 14, padding: 24, gap: 24, backgroundColor: PANEL_BG }]}>
207
+ <SwatchGroup label="CONTROLLED — SILVER" defs={CONTROL} />
208
+ <SwatchGroup label="COLLAPSE — SHADES OF RED (light → deep by severity)" defs={COLLAPSE} />
209
+ <SwatchGroup label="PHASE AXIS — ghost band tones" defs={PHASE_AXIS} />
210
+ </View>
211
+
212
+ <Text
213
+ style={{
214
+ fontSize: 11,
215
+ fontFamily: FONT_UI,
216
+ color: t['text-tertiary'],
217
+ fontStyle: 'italic',
218
+ }}
219
+ >
220
+ Used by: RomProgressionChart, GhostSpark, DualGhostLine (dual ghost-line). Source of truth:
221
+ `fatigue-tokens.ts` (SILVER, DRIFT_GREY, RED_LIGHT/MID/DEEP, PHASE_AXIS_COLOR,
222
+ `ghostLineColor`).
223
+ </Text>
224
+ </View>
225
+ ),
226
+ }
@@ -0,0 +1,70 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { VelocityHero } from './VelocityHero'
4
+ import { primitiveColors } from '../../../theme/tokens/primitives'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+ import { MOCK_MEAN_VELOCITIES } from './fatigue-mock'
7
+
8
+ const PAGE_BG = primitiveColors.charcoal[900]
9
+ const PANEL_BG = primitiveColors.charcoal[800]
10
+ const t = getSemanticColors('dark')
11
+
12
+ const meta: Meta<typeof VelocityHero> = {
13
+ title: 'Workout/DataViz/VelocityHero',
14
+ component: VelocityHero,
15
+ tags: ['autodocs'],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ 'The shipped `VelocityStrip` hero with LOSS-RELATIVE VL20/VL30 decision bands layered behind ' +
21
+ 'the bars (on the hero’s own peak scale). The velocity-loss language lives here so the ' +
22
+ 'fatigue card carries no separate VL chart. (Deferred: recolouring the bar FILL loss-relative ' +
23
+ 'needs a new VelocityStrip prop — this component owns the band overlay only.)',
24
+ },
25
+ },
26
+ },
27
+ argTypes: {
28
+ targetReps: { control: { type: 'number', min: 1, max: 12 } },
29
+ height: { control: { type: 'number', min: 160, max: 360, step: 4 } },
30
+ },
31
+ }
32
+ export default meta
33
+ type Story = StoryObj<typeof VelocityHero>
34
+
35
+ export const Default: Story = {
36
+ args: { velocities: MOCK_MEAN_VELOCITIES, targetReps: 8, width: 800, height: 320 },
37
+ render: (args) => (
38
+ <View style={{ backgroundColor: PAGE_BG, padding: 28 }}>
39
+ <View style={{ backgroundColor: PANEL_BG, borderRadius: 12, padding: 16, gap: 10 }}>
40
+ <Text
41
+ style={{
42
+ fontSize: 9,
43
+ letterSpacing: 1,
44
+ fontFamily: 'monospace',
45
+ color: t['text-tertiary'],
46
+ }}
47
+ >
48
+ 8-REP SET · bars = per-rep mean concentric velocity · bands = VL decision zones
49
+ </Text>
50
+ <VelocityHero {...args} />
51
+ </View>
52
+ </View>
53
+ ),
54
+ }
55
+
56
+ /** Mid-set — only the reps landed so far, with dashed placeholders for the reps to come. */
57
+ export const MidSet: Story = {
58
+ render: () => (
59
+ <View style={{ backgroundColor: PAGE_BG, padding: 28 }}>
60
+ <View style={{ backgroundColor: PANEL_BG, borderRadius: 12, padding: 16 }}>
61
+ <VelocityHero
62
+ velocities={MOCK_MEAN_VELOCITIES.slice(0, 4)}
63
+ targetReps={8}
64
+ width={800}
65
+ height={320}
66
+ />
67
+ </View>
68
+ </View>
69
+ ),
70
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { VelocityHero } from './VelocityHero'
4
+ import { MOCK_MEAN_VELOCITIES } from './fatigue-mock'
5
+
6
+ describe('VelocityHero', () => {
7
+ it('renders the hero container', () => {
8
+ render(
9
+ <VelocityHero velocities={MOCK_MEAN_VELOCITIES} targetReps={8} width={800} height={300} />
10
+ )
11
+ expect(screen.getByTestId('velocity-hero')).toBeInTheDocument()
12
+ })
13
+
14
+ it('draws the VL20 / VL30 loss bands when there is data', () => {
15
+ render(
16
+ <VelocityHero velocities={MOCK_MEAN_VELOCITIES} targetReps={8} width={800} height={300} />
17
+ )
18
+ expect(screen.getByText('VL 20%')).toBeInTheDocument()
19
+ expect(screen.getByText('VL 30%')).toBeInTheDocument()
20
+ })
21
+
22
+ it('draws no bands when there is no data', () => {
23
+ render(<VelocityHero velocities={[]} width={800} height={300} />)
24
+ expect(screen.queryByText('VL 20%')).not.toBeInTheDocument()
25
+ })
26
+ })
@@ -0,0 +1,46 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * VelocityHero — the primary live read beside the fatigue card: the shipped
4
+ * {@link VelocityStrip} hero in its loss-relative mode. Both halves of the
5
+ * loss-relative language now live in VelocityStrip itself — the LOSS bar fill
6
+ * (`barColor="loss"`, the default) AND the VL20 / VL30 decision bands behind the
7
+ * bars (`showLossBands`, the {@link VelocityLossBands} primitive). This component
8
+ * is a thin semantic alias (panel default height + live-rep default) so the
9
+ * fatigue card can drop in "the velocity hero" without re-specifying the mode.
10
+ */
11
+ import { View } from 'react-native'
12
+ import { VelocityStrip } from '../Workout/VelocityStrip'
13
+
14
+ export interface VelocityHeroProps {
15
+ /** Per-rep MEAN concentric velocity (m/s), ordered by rep. */
16
+ velocities: number[]
17
+ /** The set's planned rep count — draws dashed placeholder stubs for reps still to come. */
18
+ targetReps?: number
19
+ /** Index of the most-recently-completed rep (the live-rep grow). Defaults to the last performed rep. */
20
+ liveRepIndex?: number
21
+ /** Fixed width (standalone). Omit to flex-fill the column (in the panel). */
22
+ width?: number
23
+ /** Hero plot height in px. Default 300. */
24
+ height?: number
25
+ }
26
+
27
+ export function VelocityHero({
28
+ velocities,
29
+ targetReps,
30
+ liveRepIndex,
31
+ width,
32
+ height = 300,
33
+ }: VelocityHeroProps) {
34
+ return (
35
+ <View testID="velocity-hero" style={width != null ? { width, height } : { flex: 1, height }}>
36
+ <VelocityStrip
37
+ variant="hero"
38
+ velocities={velocities}
39
+ liveRepIndex={liveRepIndex ?? velocities.length - 1}
40
+ targetReps={targetReps}
41
+ height={height}
42
+ scale="peak"
43
+ />
44
+ </View>
45
+ )
46
+ }
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { VerdictHero } from './VerdictHero'
4
+ import { primitiveColors } from '../../../theme/tokens/primitives'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+ import { FATIGUE_STATES, WARMING_UP_MODEL } from './fatigue-mock'
7
+
8
+ const PAGE_BG = primitiveColors.charcoal[900]
9
+ const t = getSemanticColors('dark')
10
+
11
+ const meta: Meta<typeof VerdictHero> = {
12
+ title: 'Workout/Fatigue/Verdict Hero',
13
+ component: VerdictHero,
14
+ tags: ['autodocs'],
15
+ parameters: {
16
+ docs: {
17
+ description: {
18
+ component:
19
+ 'The card focal read: a big tone-flooded RPE number + the verdict word. RPE-led — no ' +
20
+ 'reps-in-reserve line. `verdict === null` renders a neutral "Warming up".',
21
+ },
22
+ },
23
+ },
24
+ }
25
+ export default meta
26
+ type Story = StoryObj<typeof VerdictHero>
27
+
28
+ export const AcrossStates: Story = {
29
+ render: () => (
30
+ <View
31
+ style={{
32
+ backgroundColor: PAGE_BG,
33
+ padding: 28,
34
+ flexDirection: 'row',
35
+ gap: 40,
36
+ flexWrap: 'wrap',
37
+ }}
38
+ >
39
+ {[
40
+ ...FATIGUE_STATES.map((s) => ({ name: s.name, model: s.model })),
41
+ { name: 'WARMING UP', model: WARMING_UP_MODEL },
42
+ ].map((s) => (
43
+ <View key={s.name} style={{ gap: 8, width: 240 }}>
44
+ <Text
45
+ style={{
46
+ fontSize: 9,
47
+ letterSpacing: 1,
48
+ fontFamily: 'monospace',
49
+ color: t['text-tertiary'],
50
+ }}
51
+ >
52
+ {s.name}
53
+ </Text>
54
+ <VerdictHero rpe={s.model.rpe} verdict={s.model.verdict} />
55
+ </View>
56
+ ))}
57
+ </View>
58
+ ),
59
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { VerdictHero } from './VerdictHero'
5
+ import type { FatigueVerdict } from './fatigue-model'
6
+
7
+ const goodVerdict: FatigueVerdict = {
8
+ state: 'good',
9
+ tone: 'ok',
10
+ dimensions: { velocityLoss: 'ok', rom: 'ok', tempo: 'ok' },
11
+ }
12
+ const breakdown: FatigueVerdict = {
13
+ state: 'form-breakdown',
14
+ tone: 'alarm',
15
+ dimensions: { velocityLoss: 'alarm', rom: 'alarm', tempo: 'alarm' },
16
+ }
17
+
18
+ describe('VerdictHero', () => {
19
+ it('rounds the RPE to the conventional 0.5 step', () => {
20
+ render(<VerdictHero rpe={7.4} verdict={goodVerdict} />)
21
+ expect(screen.getByText('7.5')).toBeInTheDocument()
22
+ })
23
+
24
+ it('renders the verdict word for the state', () => {
25
+ render(<VerdictHero rpe={10} verdict={breakdown} />)
26
+ expect(screen.getByText('Form breaking down')).toBeInTheDocument()
27
+ })
28
+
29
+ it('shows an em-dash and "Warming up" when the verdict is null', () => {
30
+ render(<VerdictHero rpe={null} verdict={null} />)
31
+ expect(screen.getByText('—')).toBeInTheDocument()
32
+ expect(screen.getByText('Warming up')).toBeInTheDocument()
33
+ })
34
+
35
+ it('does not render a reps-in-reserve line (RPE-led only)', () => {
36
+ render(<VerdictHero rpe={8} verdict={goodVerdict} />)
37
+ expect(screen.queryByText(/in reserve/i)).not.toBeInTheDocument()
38
+ })
39
+
40
+ it('labels the RPE number for assistive tech', () => {
41
+ render(<VerdictHero rpe={9} verdict={goodVerdict} />)
42
+ expect(screen.getByLabelText('RPE 9.0')).toBeInTheDocument()
43
+ })
44
+
45
+ it('has no accessibility violations', async () => {
46
+ const { container } = render(<VerdictHero rpe={8} verdict={goodVerdict} />)
47
+ expect(await axe(container)).toHaveNoViolations()
48
+ })
49
+ })
@@ -0,0 +1,79 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * VerdictHero — the fatigue card's single focal read: a big tone-flooded RPE number
4
+ * beside the aggregated verdict word (Good / Slowing / Grinding / Form breaking
5
+ * down). Integrated straight into the card (no nested box), in the StopSetDecision
6
+ * idiom. RPE-LED by design — there is deliberately NO reps-in-reserve line (the card
7
+ * covers exertion state with the number + word alone).
8
+ *
9
+ * Warming up (a cold-start set, `verdict === null`) renders a neutral "Warming up"
10
+ * with an em-dash RPE.
11
+ */
12
+ import { View, Text } from 'react-native'
13
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
14
+ import { FONT_HEAD, FONT_MONO, TONE_COLOR, STATE_LABEL } from './fatigue-tokens'
15
+ import type { FatigueVerdict } from './fatigue-model'
16
+
17
+ const t = getSemanticColors('dark')
18
+
19
+ export interface VerdictHeroProps {
20
+ /** Exact (unrounded) RPE estimate; rounded to the conventional 0.5 for display. `null` = warming up. */
21
+ rpe: number | null
22
+ /** The aggregated verdict + tone. `null` = warming up (renders neutral). */
23
+ verdict: FatigueVerdict | null
24
+ }
25
+
26
+ /** Round an exact RPE to the conventional 0.5 step; em-dash when absent. */
27
+ function formatRpe(rpe: number | null): string {
28
+ if (rpe == null) return '—'
29
+ return (Math.round(rpe * 2) / 2).toFixed(1)
30
+ }
31
+
32
+ export function VerdictHero({ rpe, verdict }: VerdictHeroProps) {
33
+ const tone = verdict ? TONE_COLOR[verdict.tone] : t['text-tertiary']
34
+ const word = verdict ? STATE_LABEL[verdict.state] : 'Warming up'
35
+ return (
36
+ <View style={{ gap: 6 }} testID="verdict-hero">
37
+ <Text
38
+ style={{
39
+ fontSize: 9,
40
+ letterSpacing: 1.5,
41
+ fontFamily: FONT_MONO,
42
+ color: t['text-tertiary'],
43
+ }}
44
+ >
45
+ FATIGUE
46
+ </Text>
47
+ <View style={{ gap: 2 }}>
48
+ <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 7 }}>
49
+ <Text
50
+ style={{
51
+ fontSize: 62,
52
+ fontWeight: '900',
53
+ fontFamily: FONT_HEAD,
54
+ color: tone,
55
+ lineHeight: 60,
56
+ }}
57
+ accessibilityLabel={`RPE ${formatRpe(rpe)}`}
58
+ >
59
+ {formatRpe(rpe)}
60
+ </Text>
61
+ <Text
62
+ style={{
63
+ fontSize: 18,
64
+ fontWeight: '800',
65
+ color: tone,
66
+ marginBottom: 9,
67
+ fontFamily: FONT_HEAD,
68
+ }}
69
+ >
70
+ RPE
71
+ </Text>
72
+ </View>
73
+ <Text style={{ fontSize: 17, fontWeight: '800', fontFamily: FONT_HEAD, color: tone }}>
74
+ {word}
75
+ </Text>
76
+ </View>
77
+ </View>
78
+ )
79
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ buildMockModel,
4
+ buildMockPanelState,
5
+ MOCK_PLANNED_REPS,
6
+ MOCK_MEAN_VELOCITIES,
7
+ FATIGUE_STATES,
8
+ } from './fatigue-mock'
9
+
10
+ /**
11
+ * The panel puts the ROM chart and the velocity strip side by side, and each draws its own
12
+ * upcoming-rep remainder from a SEPARATE prop. These lock the two to one number — the drift
13
+ * they prevent is visible on screen as two different rep counts on one panel.
14
+ */
15
+ describe('buildMockPanelState', () => {
16
+ it('gives the ROM chart and the velocity strip the SAME planned rep count', () => {
17
+ const { model, velocity } = buildMockPanelState(2)
18
+ expect(model.plannedReps).toBe(velocity.targetReps)
19
+ })
20
+
21
+ it('truncates the model and the velocities at the same point', () => {
22
+ for (const current of [0, 2, 5, 7]) {
23
+ const { model, velocity } = buildMockPanelState(current)
24
+ expect(model.romProgression).toHaveLength(current + 1)
25
+ expect(model.velocityCurves).toHaveLength(current + 1)
26
+ expect(velocity.velocities).toHaveLength(current + 1)
27
+ expect(velocity.liveRepIndex).toBe(current)
28
+ }
29
+ })
30
+
31
+ it('never plans fewer reps than it has performed', () => {
32
+ for (const s of FATIGUE_STATES) {
33
+ const { model, velocity } = buildMockPanelState(s.current)
34
+ expect(model.plannedReps!).toBeGreaterThanOrEqual(model.romProgression.length)
35
+ expect(velocity.targetReps).toBeGreaterThanOrEqual(velocity.velocities.length)
36
+ }
37
+ })
38
+
39
+ it('counts the header rep out of the same planned total', () => {
40
+ const { header } = buildMockPanelState(3)
41
+ expect(header.meta).toBe(`SET 3 · REP 4 / ${MOCK_PLANNED_REPS}`)
42
+ })
43
+
44
+ it('derives the planned count from the rep data, not a literal', () => {
45
+ expect(MOCK_PLANNED_REPS).toBe(MOCK_MEAN_VELOCITIES.length)
46
+ })
47
+ })
48
+
49
+ describe('buildMockModel plannedReps', () => {
50
+ it('attaches the mock plan by default', () => {
51
+ expect(buildMockModel(2).plannedReps).toBe(MOCK_PLANNED_REPS)
52
+ })
53
+
54
+ it('honours an EXPLICIT undefined as "no plan attached"', () => {
55
+ const model = buildMockModel(2, { plannedReps: undefined })
56
+ expect(model.plannedReps).toBeUndefined()
57
+ expect('plannedReps' in model).toBe(true)
58
+ })
59
+ })