@titan-design/react-ui 0.9.3 → 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.
- package/dist/bodymap.js +78 -24
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +78 -24
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +429 -11
- package/dist/index.d.ts +429 -11
- package/dist/index.js +2386 -885
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2306 -836
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1900 -1609
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1901 -1610
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +129 -34
- package/dist/theme/index.d.ts +129 -34
- package/dist/theme/index.js +95 -19
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +92 -20
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +88 -24
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +88 -24
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Workout/BaseBadge.tsx +5 -5
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
- package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +55 -18
- package/src/components/custom/Workout/SetRow.test.tsx +6 -6
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/surface/Surface.stories.tsx +195 -1
- package/src/components/ui/surface/Surface.test.tsx +148 -9
- package/src/components/ui/surface/Surface.tsx +37 -9
- package/src/components/ui/surface/SurfaceContext.ts +48 -8
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +871 -0
- package/src/theme/ColorPrimitives.stories.tsx +300 -0
- package/src/theme/bar-paper.ts +59 -0
- package/src/theme/color-stories.coverage.test.ts +130 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/elevation.ts +90 -67
- package/src/theme/global.css +35 -9
- package/src/theme/tokens/primitives.ts +40 -0
- package/src/theme/tokens/semantic.ts +63 -14
- package/tailwind.config.js +8 -0
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -323
- 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'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
|
+
})
|