@titan-design/react-ui 0.10.0 → 0.12.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 +173 -343
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +173 -343
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +416 -18
- package/dist/index.d.ts +416 -18
- package/dist/index.js +2512 -1307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2480 -1299
- 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 +1644 -1575
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1645 -1576
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +409 -415
- package/dist/theme/index.d.ts +409 -415
- package/dist/theme/index.js +234 -408
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +227 -399
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +141 -140
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +141 -140
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/arch/Architecture.stories.tsx +9 -9
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- 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/Sidebar/Sidebar.tsx +4 -4
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +2 -2
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
- package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
- package/src/components/custom/Workout/DeviationBar.tsx +3 -2
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -2
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
- package/src/components/custom/Workout/MesoCard.tsx +3 -1
- package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
- package/src/components/custom/Workout/RestTimer.tsx +2 -2
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionHeader.tsx +1 -1
- package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +63 -18
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/StatusDot.tsx +5 -4
- package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
- 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 +199 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
- package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
- package/src/components/custom/Workout/WeightBadge.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
- package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
- 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/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +3 -3
- package/src/components/shell/DeviceMenu.tsx +2 -2
- package/src/components/shell/SideNav.tsx +1 -1
- package/src/components/shell/TopBar.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/avatar/Avatar.tsx +2 -2
- package/src/components/ui/badge/Badge.tsx +2 -2
- package/src/components/ui/card/Card.tsx +3 -3
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +2 -2
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +3 -3
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.tsx +4 -4
- package/src/components/ui/select/Select.tsx +3 -3
- package/src/components/ui/surface/Surface.stories.tsx +3 -3
- package/src/components/ui/surface/Surface.test.tsx +32 -19
- package/src/components/ui/surface/Surface.tsx +3 -3
- package/src/components/ui/surface/SurfaceContext.ts +19 -29
- package/src/components/ui/surface/surface.contract.test.ts +57 -44
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
- package/src/stories/PresetShowcase.stories.tsx +21 -21
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +874 -0
- package/src/theme/ColorPrimitives.stories.tsx +348 -0
- package/src/theme/Depth.stories.tsx +312 -0
- package/src/theme/DepthCalibration.stories.tsx +655 -0
- package/src/theme/ThemeProvider.tsx +1 -1
- package/src/theme/barrel.ts +7 -14
- package/src/theme/color-stories.coverage.test.ts +125 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/color-utils.ts +197 -0
- package/src/theme/config.ts +0 -8
- package/src/theme/depth-calibration.test.tsx +97 -0
- package/src/theme/elevation.test.ts +62 -1
- package/src/theme/elevation.ts +34 -8
- package/src/theme/global.css +47 -53
- package/src/theme/grey-ramp.test.ts +166 -0
- package/src/theme/materials.test.ts +106 -0
- package/src/theme/materials.ts +211 -0
- package/src/theme/presets/audiobook.ts +4 -4
- package/src/theme/presets/types.ts +3 -3
- package/src/theme/semantic-contrast.test.ts +83 -0
- package/src/theme/tokens/primitives.ts +62 -115
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- 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 -508
- package/src/theme/Colors.stories.tsx +0 -323
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* Realistic mock {@link LiveFatigueModel}s for the Fatigue family stories + tests — a
|
|
4
|
+
* fatiguing 8-rep cable chest press taken deep (velocity decays, ROM shrinks, tempo
|
|
5
|
+
* degrades; reps 4 & 7 are cheats). Ported from the design-lab specimen so the
|
|
6
|
+
* hardened components render against the same shape the wall will feed them. NOT
|
|
7
|
+
* shipped in app bundles — story/test fixture only.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
FatigueVerdict,
|
|
11
|
+
LiveFatigueModel,
|
|
12
|
+
PhaseSegment,
|
|
13
|
+
RepVelocityCurve,
|
|
14
|
+
SamplePhase,
|
|
15
|
+
VelocitySample,
|
|
16
|
+
} from './fatigue-model'
|
|
17
|
+
|
|
18
|
+
interface RepSpec {
|
|
19
|
+
romMm: number
|
|
20
|
+
eccMs: number
|
|
21
|
+
pBMs: number
|
|
22
|
+
conMs: number
|
|
23
|
+
pTMs: number
|
|
24
|
+
eccControl: number
|
|
25
|
+
conPower: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SPECS: RepSpec[] = [
|
|
29
|
+
{ romMm: 900, eccMs: 2600, pBMs: 420, conMs: 950, pTMs: 300, eccControl: 0.95, conPower: 0.9 },
|
|
30
|
+
{ romMm: 895, eccMs: 2650, pBMs: 380, conMs: 1000, pTMs: 250, eccControl: 0.94, conPower: 0.86 },
|
|
31
|
+
{ romMm: 880, eccMs: 2500, pBMs: 340, conMs: 1100, pTMs: 160, eccControl: 0.9, conPower: 0.78 },
|
|
32
|
+
{ romMm: 715, eccMs: 1300, pBMs: 120, conMs: 900, pTMs: 90, eccControl: 0.34, conPower: 0.82 },
|
|
33
|
+
{ romMm: 840, eccMs: 2300, pBMs: 300, conMs: 1500, pTMs: 90, eccControl: 0.85, conPower: 0.44 },
|
|
34
|
+
{ romMm: 800, eccMs: 2000, pBMs: 240, conMs: 1750, pTMs: 80, eccControl: 0.68, conPower: 0.34 },
|
|
35
|
+
{ romMm: 645, eccMs: 1200, pBMs: 100, conMs: 1650, pTMs: 70, eccControl: 0.3, conPower: 0.4 },
|
|
36
|
+
{ romMm: 600, eccMs: 1250, pBMs: 70, conMs: 2300, pTMs: 60, eccControl: 0.34, conPower: 0.24 },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** Prescribed tempo (ms per phase). */
|
|
40
|
+
const TARGET = { ecc: 2600, pauseBottom: 400, con: 950, pauseTop: 280 }
|
|
41
|
+
export const TARGET_TEMPO_SECONDS: [number, number, number, number] = [
|
|
42
|
+
TARGET.ecc / 1000,
|
|
43
|
+
TARGET.pauseBottom / 1000,
|
|
44
|
+
TARGET.con / 1000,
|
|
45
|
+
TARGET.pauseTop / 1000,
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
type LabPhase = 'ecc' | 'pauseBottom' | 'con' | 'pauseTop'
|
|
49
|
+
// Both prescribed pauses are HOLDS (deliberate, under load), not idle dead time — the
|
|
50
|
+
// lab's tempo prescribes them, so the band names them.
|
|
51
|
+
const LAB_TO_MODEL: Record<LabPhase, SamplePhase> = {
|
|
52
|
+
ecc: 'eccentric',
|
|
53
|
+
pauseBottom: 'hold',
|
|
54
|
+
con: 'concentric',
|
|
55
|
+
pauseTop: 'hold',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
59
|
+
return Math.min(hi, Math.max(lo, v))
|
|
60
|
+
}
|
|
61
|
+
function hashNoise(i: number): number {
|
|
62
|
+
const x = Math.sin(i * 127.1 + 11.7) * 43758.5453
|
|
63
|
+
return x - Math.floor(x)
|
|
64
|
+
}
|
|
65
|
+
function eccVelMm(u: number, s: RepSpec): number {
|
|
66
|
+
const mean = s.romMm / (s.eccMs / 1000)
|
|
67
|
+
const peak = mean * (Math.PI / 2)
|
|
68
|
+
const skew = 0.5 - (1 - s.eccControl) * 0.22
|
|
69
|
+
const sharp = 1 + (1 - s.eccControl) * 1.3
|
|
70
|
+
const hump = Math.pow(Math.sin(Math.PI * Math.pow(u, skew)), sharp)
|
|
71
|
+
return peak * hump * (1 + (1 - s.eccControl) * 0.7)
|
|
72
|
+
}
|
|
73
|
+
function conVelMm(u: number, s: RepSpec): number {
|
|
74
|
+
const mean = s.romMm / (s.conMs / 1000)
|
|
75
|
+
const peak = mean * (Math.PI / 2)
|
|
76
|
+
const skew = 0.5 - s.conPower * 0.18
|
|
77
|
+
const stick = (1 - s.conPower) * 0.32 * Math.exp(-Math.pow((u - 0.46) / 0.16, 2))
|
|
78
|
+
const shape = Math.max(0.02, Math.sin(Math.PI * Math.pow(u, skew)) - stick)
|
|
79
|
+
return peak * shape * (0.72 + s.conPower * 0.5)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function genRep(spec: RepSpec, repNumber: number): RepVelocityCurve {
|
|
83
|
+
const segs: Array<{ phase: LabPhase; dur: number }> = [
|
|
84
|
+
{ phase: 'ecc', dur: spec.eccMs },
|
|
85
|
+
{ phase: 'pauseBottom', dur: spec.pBMs },
|
|
86
|
+
{ phase: 'con', dur: spec.conMs },
|
|
87
|
+
{ phase: 'pauseTop', dur: spec.pTMs },
|
|
88
|
+
]
|
|
89
|
+
const total = spec.eccMs + spec.pBMs + spec.conMs + spec.pTMs
|
|
90
|
+
const dt = 1000 / 11
|
|
91
|
+
const samples: VelocitySample[] = []
|
|
92
|
+
const segments: PhaseSegment[] = []
|
|
93
|
+
let n = 0
|
|
94
|
+
for (let time = 0; time <= total + 1; time += dt) {
|
|
95
|
+
let acc = 0
|
|
96
|
+
let phase: LabPhase = 'pauseTop'
|
|
97
|
+
let u = 0
|
|
98
|
+
for (let k = 0; k < segs.length; k++) {
|
|
99
|
+
if (time < acc + segs[k].dur || k === segs.length - 1) {
|
|
100
|
+
phase = segs[k].phase
|
|
101
|
+
u = Math.min(1, Math.max(0, (time - acc) / Math.max(segs[k].dur, 1)))
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
acc += segs[k].dur
|
|
105
|
+
}
|
|
106
|
+
let velMm = 0
|
|
107
|
+
if (phase === 'ecc') velMm = eccVelMm(u, spec)
|
|
108
|
+
else if (phase === 'con') velMm = conVelMm(u, spec)
|
|
109
|
+
velMm *= 1 + (hashNoise(n) - 0.5) * 0.05
|
|
110
|
+
n += 1
|
|
111
|
+
samples.push({
|
|
112
|
+
tMs: Math.round(time),
|
|
113
|
+
velocityMps: Math.abs(velMm) / 1000,
|
|
114
|
+
phase: LAB_TO_MODEL[phase],
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
// Contiguous phase runs (bridge the seam so segments abut).
|
|
118
|
+
samples.forEach((s) => {
|
|
119
|
+
const last = segments[segments.length - 1]
|
|
120
|
+
if (!last || last.phase !== s.phase)
|
|
121
|
+
segments.push({ phase: s.phase, startMs: s.tMs, endMs: s.tMs })
|
|
122
|
+
else last.endMs = s.tMs
|
|
123
|
+
})
|
|
124
|
+
// tempoDeviation — normalized concentric-duration deviation from prescribed con.
|
|
125
|
+
const tempoDeviation = clamp(Math.abs(spec.conMs - TARGET.con) / TARGET.con, 0, 1)
|
|
126
|
+
// grindSignature — mid-concentric collapse; low conPower = grinding (cheats stay low, they're fast).
|
|
127
|
+
const grindSignature = clamp((1 - spec.conPower) * 1.1, 0, 1)
|
|
128
|
+
return { repNumber, samples, phaseSegments: segments, tempoDeviation, grindSignature }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const CURVES: RepVelocityCurve[] = SPECS.map((s, i) => genRep(s, i + 1))
|
|
132
|
+
|
|
133
|
+
/** Per-rep RPE estimate through the fatiguing set (the cheat rep 4 dips). */
|
|
134
|
+
const RPE: Array<number | null> = [null, 6.5, 7.5, 7, 8.5, 9, 9.5, 10]
|
|
135
|
+
|
|
136
|
+
/** Working ROM standard (metres): trimmed peak — drop rep 1 + the current rep. `null` under 3 reps. */
|
|
137
|
+
function workingStandard(current: number): number | null {
|
|
138
|
+
const trimmed = SPECS.slice(1, current).map((s) => s.romMm / 1000)
|
|
139
|
+
if (trimmed.length === 0) return null
|
|
140
|
+
return Math.max(...trimmed)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build a mock model for the set truncated at `current` (0-based rep index). Pass a
|
|
145
|
+
* `verdict` / `rpe` to override the plausible derived read (used by the state showcase).
|
|
146
|
+
*/
|
|
147
|
+
export function buildMockModel(
|
|
148
|
+
current: number,
|
|
149
|
+
overrides?: {
|
|
150
|
+
rpe?: number | null
|
|
151
|
+
verdict?: FatigueVerdict | null
|
|
152
|
+
/** Override the plan. Pass `undefined` explicitly for the no-plan-attached case. */
|
|
153
|
+
plannedReps?: number
|
|
154
|
+
}
|
|
155
|
+
): LiveFatigueModel {
|
|
156
|
+
const spec = SPECS[current]
|
|
157
|
+
const working = workingStandard(current)
|
|
158
|
+
const rpe = overrides && 'rpe' in overrides ? (overrides.rpe ?? null) : (RPE[current] ?? null)
|
|
159
|
+
const verdict = overrides && 'verdict' in overrides ? (overrides.verdict ?? null) : null
|
|
160
|
+
const plannedReps =
|
|
161
|
+
overrides && 'plannedReps' in overrides ? overrides.plannedReps : MOCK_PLANNED_REPS
|
|
162
|
+
return {
|
|
163
|
+
rpe,
|
|
164
|
+
repsInReserve: rpe == null ? null : Math.max(0, Math.round(10 - rpe)),
|
|
165
|
+
verdict,
|
|
166
|
+
romProgression: SPECS.slice(0, current + 1).map((s, i) => ({
|
|
167
|
+
repNumber: i + 1,
|
|
168
|
+
romM: s.romMm / 1000,
|
|
169
|
+
})),
|
|
170
|
+
plannedReps,
|
|
171
|
+
romWorkingStandardM: working,
|
|
172
|
+
romShortThresholdM: working != null ? working * 0.75 : null,
|
|
173
|
+
velocityCurves: CURVES.slice(0, current + 1),
|
|
174
|
+
tempoSeconds: [spec.eccMs / 1000, spec.pBMs / 1000, spec.conMs / 1000, spec.pTMs / 1000],
|
|
175
|
+
targetTempoSeconds: TARGET_TEMPO_SECONDS,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The WHOLE panel's input for a set truncated at `current` — model, velocity and header
|
|
181
|
+
* from one rep count and one truncation point.
|
|
182
|
+
*
|
|
183
|
+
* The panel feeds two components that each draw their own upcoming-rep remainder, so
|
|
184
|
+
* assembling their props separately is how the two halves drift apart. Build both here
|
|
185
|
+
* or they will disagree again.
|
|
186
|
+
*/
|
|
187
|
+
export function buildMockPanelState(
|
|
188
|
+
current: number,
|
|
189
|
+
overrides?: { rpe?: number | null; verdict?: FatigueVerdict | null }
|
|
190
|
+
): {
|
|
191
|
+
model: LiveFatigueModel
|
|
192
|
+
velocity: { velocities: number[]; targetReps: number; liveRepIndex: number }
|
|
193
|
+
header: typeof MOCK_HEADER
|
|
194
|
+
} {
|
|
195
|
+
const model = buildMockModel(current, overrides)
|
|
196
|
+
return {
|
|
197
|
+
model,
|
|
198
|
+
velocity: {
|
|
199
|
+
// Same truncation as the model's romProgression / velocityCurves.
|
|
200
|
+
velocities: MOCK_MEAN_VELOCITIES.slice(0, current + 1),
|
|
201
|
+
targetReps: MOCK_PLANNED_REPS,
|
|
202
|
+
liveRepIndex: current,
|
|
203
|
+
},
|
|
204
|
+
header: { ...MOCK_HEADER, meta: `SET 3 · REP ${current + 1} / ${MOCK_PLANNED_REPS}` },
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Per-rep MEAN concentric velocity (m/s) for the velocity hero (mm/ms == m/s). */
|
|
209
|
+
export const MOCK_MEAN_VELOCITIES: number[] = SPECS.map((s) => s.romMm / s.conMs)
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The prescribed rep count for the mock set — ONE number for the whole family.
|
|
213
|
+
*
|
|
214
|
+
* The fatigue card's ROM chart and the velocity hero beside it both draw an
|
|
215
|
+
* upcoming-rep remainder, from `plannedReps` and `targetReps` respectively. Those
|
|
216
|
+
* are separate props on separate components, so a story that hardcodes one and
|
|
217
|
+
* leaves the other unset puts two different rep counts side by side on one panel.
|
|
218
|
+
* Derive both from here.
|
|
219
|
+
*/
|
|
220
|
+
export const MOCK_PLANNED_REPS = SPECS.length
|
|
221
|
+
|
|
222
|
+
const V = (
|
|
223
|
+
state: FatigueVerdict['state'],
|
|
224
|
+
tone: FatigueVerdict['tone'],
|
|
225
|
+
vel: FatigueVerdict['dimensions']['velocityLoss'],
|
|
226
|
+
rom: FatigueVerdict['dimensions']['rom'],
|
|
227
|
+
tempo: FatigueVerdict['dimensions']['tempo']
|
|
228
|
+
): FatigueVerdict => ({
|
|
229
|
+
state,
|
|
230
|
+
tone,
|
|
231
|
+
dimensions: { velocityLoss: vel, rom, tempo },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/** The four verdict states, each a full plausible model — the "Live states" showcase. */
|
|
235
|
+
export const FATIGUE_STATES: Array<{ name: string; current: number; model: LiveFatigueModel }> = [
|
|
236
|
+
{
|
|
237
|
+
name: 'GOOD · early set',
|
|
238
|
+
current: 1,
|
|
239
|
+
model: buildMockModel(1, { rpe: 6, verdict: V('good', 'ok', 'ok', 'ok', 'ok') }),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'SLOWING · bar speed dropping',
|
|
243
|
+
current: 2,
|
|
244
|
+
model: buildMockModel(2, { rpe: 7.5, verdict: V('slowing', 'warn', 'warn', 'ok', 'ok') }),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'GRINDING · deep loss, clean form',
|
|
248
|
+
current: 5,
|
|
249
|
+
model: buildMockModel(5, { rpe: 9, verdict: V('grinding', 'warn', 'alarm', 'ok', 'ok') }),
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'FORM BREAKING DOWN · cheat rep',
|
|
253
|
+
current: 7,
|
|
254
|
+
model: buildMockModel(7, {
|
|
255
|
+
rpe: 10,
|
|
256
|
+
verdict: V('form-breakdown', 'alarm', 'alarm', 'alarm', 'alarm'),
|
|
257
|
+
}),
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
/** A warming-up (cold-start, < 2 reps) model — null verdict / RPE. */
|
|
262
|
+
export const WARMING_UP_MODEL: LiveFatigueModel = buildMockModel(0, { rpe: null, verdict: null })
|
|
263
|
+
|
|
264
|
+
export const MOCK_HEADER = {
|
|
265
|
+
title: 'Cable Chest Press',
|
|
266
|
+
subtitle: `Push A · Hypertrophy · 62 lb × ${MOCK_PLANNED_REPS} · tempo 2.6·0.4·0.95·0.28`,
|
|
267
|
+
meta: `SET 3 · REP ${MOCK_PLANNED_REPS} / ${MOCK_PLANNED_REPS}`,
|
|
268
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* The live-fatigue data contract — a titan-local mirror of voltras-mcp's
|
|
4
|
+
* `LiveFatigueModel` (`src/dashboard/spa/live-page/fatigue-model.ts`). The card
|
|
5
|
+
* family consumes ONE typed `LiveFatigueModel` prop; the diverging/velocity hero
|
|
6
|
+
* beside it takes its own per-rep velocity array (that data is NOT on this model —
|
|
7
|
+
* it comes from the live-view velocity path).
|
|
8
|
+
*
|
|
9
|
+
* Units: every velocity is m/s and every distance is metres (converted from WA's
|
|
10
|
+
* native mm/s & mm at the mapper boundary). Tone/state vocabularies mirror WA's
|
|
11
|
+
* `DimensionTone` / `FatigueVerdictState` one-for-one so the real WA verdict drops
|
|
12
|
+
* straight in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Per-dimension status light. Mirrors WA's `DimensionTone`. */
|
|
16
|
+
export type DimensionTone = 'ok' | 'warn' | 'alarm'
|
|
17
|
+
|
|
18
|
+
/** Aggregated verdict state (drives the hero word). Mirrors WA's `FatigueVerdictState`. */
|
|
19
|
+
export type FatigueVerdictState = 'good' | 'slowing' | 'grinding' | 'form-breakdown'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Movement phase of one per-sample point — colours the ghost-spark zero-axis.
|
|
23
|
+
*
|
|
24
|
+
* `hold` is a DELIBERATE pause under load (WA's `MovementPhase.HOLD`) — the bottom/top
|
|
25
|
+
* holds a tempo prescription asks for. `idle` is undirected dead time. They were folded
|
|
26
|
+
* together until the band needed to name a hold; keep them apart, because "the lifter held
|
|
27
|
+
* the bottom for 400 ms" and "nothing happened" are different facts.
|
|
28
|
+
*/
|
|
29
|
+
export type SamplePhase = 'concentric' | 'eccentric' | 'hold' | 'idle'
|
|
30
|
+
|
|
31
|
+
/** One per-sample point of a rep's velocity-time curve. */
|
|
32
|
+
export interface VelocitySample {
|
|
33
|
+
/** Milliseconds since this rep's first sample. */
|
|
34
|
+
tMs: number
|
|
35
|
+
/** Instantaneous velocity MAGNITUDE, m/s (the ghost-spark signs it by `phase`). */
|
|
36
|
+
velocityMps: number
|
|
37
|
+
/** Movement phase at this sample — drives the phase-coloured zero-axis segment. */
|
|
38
|
+
phase: SamplePhase
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A contiguous same-phase run within a rep's sample stream — one zero-axis segment. */
|
|
42
|
+
export interface PhaseSegment {
|
|
43
|
+
phase: SamplePhase
|
|
44
|
+
/** Start offset, ms since the rep's first sample. */
|
|
45
|
+
startMs: number
|
|
46
|
+
/** End offset, ms since the rep's first sample. */
|
|
47
|
+
endMs: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One rep's velocity-time curve for the ghost-spark (current rep solid, priors faded).
|
|
52
|
+
*
|
|
53
|
+
* The line tint is driven by TWO normalized per-rep signals (the "green-intensity
|
|
54
|
+
* control-aware" rule; the mapping lives in {@link ghostLineColor}):
|
|
55
|
+
* `grindSignature < 0.35` → stay green, intensity by `tempoDeviation` (brightest at
|
|
56
|
+
* 0 → deepest green at 1, hue held); otherwise → warm hue amber (at 0.35) → red (at
|
|
57
|
+
* 1.0) by `grindSignature`. A slow-but-smooth rep stays green (just deeper); only a
|
|
58
|
+
* stalling/collapsing rep warms.
|
|
59
|
+
*/
|
|
60
|
+
export interface RepVelocityCurve {
|
|
61
|
+
/** 1-based rep number. */
|
|
62
|
+
repNumber: number
|
|
63
|
+
/** Per-sample velocity-time points, in stream order. */
|
|
64
|
+
samples: VelocitySample[]
|
|
65
|
+
/** Contiguous phase runs, for the phase-coloured zero-axis. */
|
|
66
|
+
phaseSegments: PhaseSegment[]
|
|
67
|
+
/**
|
|
68
|
+
* Normalized concentric-duration deviation from the prescribed tempo, 0..1 (0 =
|
|
69
|
+
* on-tempo, 1 = well off). Drives GREEN INTENSITY when the rep is controlled.
|
|
70
|
+
* `null` when there is no prescribed tempo to compare against.
|
|
71
|
+
*/
|
|
72
|
+
tempoDeviation: number | null
|
|
73
|
+
/**
|
|
74
|
+
* Normalized velocity COLLAPSE within the concentric, 0..1 (0 = smooth, 1 = full
|
|
75
|
+
* collapse). Warms the line hue amber→red past the control threshold. Always
|
|
76
|
+
* computed per rep; a rep with too few concentric samples reads `0`.
|
|
77
|
+
*/
|
|
78
|
+
grindSignature: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One per-rep point of the ROM-progression mini-chart. */
|
|
82
|
+
export interface RepRomPoint {
|
|
83
|
+
/** 1-based rep number. */
|
|
84
|
+
repNumber: number
|
|
85
|
+
/** Concentric range of motion, metres. */
|
|
86
|
+
romM: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The aggregated verdict + the three per-dimension lights (WA `getSetFatigueVerdict`). */
|
|
90
|
+
export interface FatigueVerdict {
|
|
91
|
+
state: FatigueVerdictState
|
|
92
|
+
tone: DimensionTone
|
|
93
|
+
dimensions: {
|
|
94
|
+
velocityLoss: DimensionTone
|
|
95
|
+
rom: DimensionTone
|
|
96
|
+
tempo: DimensionTone
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The always-on live fatigue card model for the current set. A `null` `verdict`
|
|
102
|
+
* means the set is warming up (cold start, < 2 reps) — the card renders neutral.
|
|
103
|
+
*/
|
|
104
|
+
export interface LiveFatigueModel {
|
|
105
|
+
/** Estimated RPE (exact, unrounded — the card rounds to 0.5). `null` under 2 reps. */
|
|
106
|
+
rpe: number | null
|
|
107
|
+
/** Reps in reserve = 10 − RPE. `null` when `rpe` is `null`. Carried for the contract; the card is RPE-led and does not render a reps-in-reserve line. */
|
|
108
|
+
repsInReserve: number | null
|
|
109
|
+
/** The verdict + three dimension lights. `null` = warming up. */
|
|
110
|
+
verdict: FatigueVerdict | null
|
|
111
|
+
/** ROM progression: per-rep ROM points (metres), ordered by rep. */
|
|
112
|
+
romProgression: RepRomPoint[]
|
|
113
|
+
/**
|
|
114
|
+
* Planned rep count from the prescription — the ROM chart draws the remainder as dashed
|
|
115
|
+
* to-do placeholders (the "N of M done" read). Absent/`undefined` when no plan is attached:
|
|
116
|
+
* there is genuinely no target then, and the gap must read as a gap — never defaulted.
|
|
117
|
+
*/
|
|
118
|
+
plannedReps?: number
|
|
119
|
+
/** Working-range standard the ROM chart draws its reference line at (metres). `null` until ≥ 3 reps. */
|
|
120
|
+
romWorkingStandardM: number | null
|
|
121
|
+
/** Short-threshold line (metres) = 0.75 × working standard. `null` until the standard exists. */
|
|
122
|
+
romShortThresholdM: number | null
|
|
123
|
+
/** Ghost-spark: per-rep velocity-time curves, oldest first (last = current rep). */
|
|
124
|
+
velocityCurves: RepVelocityCurve[]
|
|
125
|
+
/** Current-rep tempo tuple `[ecc, pauseBottom, con, pauseTop]` seconds. `null` when no rep carries timing. */
|
|
126
|
+
tempoSeconds: [number, number, number, number] | null
|
|
127
|
+
/** Target tempo tuple, same ordering, from the prescription. `null` when none prescribed. */
|
|
128
|
+
targetTempoSeconds: [number, number, number, number] | null
|
|
129
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ghostLineColor,
|
|
4
|
+
auraForVerdict,
|
|
5
|
+
mixHex,
|
|
6
|
+
clamp01,
|
|
7
|
+
GRIND_THRESHOLD,
|
|
8
|
+
SILVER,
|
|
9
|
+
RED_LIGHT,
|
|
10
|
+
RED_MID,
|
|
11
|
+
RED_DEEP,
|
|
12
|
+
} from './fatigue-tokens'
|
|
13
|
+
|
|
14
|
+
describe('clamp01', () => {
|
|
15
|
+
it('clamps to [0,1]', () => {
|
|
16
|
+
expect(clamp01(-1)).toBe(0)
|
|
17
|
+
expect(clamp01(2)).toBe(1)
|
|
18
|
+
expect(clamp01(0.4)).toBe(0.4)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('mixHex', () => {
|
|
23
|
+
it('returns the endpoints at 0 and 1', () => {
|
|
24
|
+
expect(mixHex('#000000', '#ffffff', 0)).toBe('#000000')
|
|
25
|
+
expect(mixHex('#000000', '#ffffff', 1)).toBe('#ffffff')
|
|
26
|
+
})
|
|
27
|
+
it('mixes at the midpoint', () => {
|
|
28
|
+
expect(mixHex('#000000', '#ffffff', 0.5)).toBe('#808080')
|
|
29
|
+
})
|
|
30
|
+
it('expands 3-digit hex', () => {
|
|
31
|
+
expect(mixHex('#000', '#fff', 1)).toBe('#ffffff')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('ghostLineColor (control-aware silver/red)', () => {
|
|
36
|
+
const reds = [RED_LIGHT, RED_MID, RED_DEEP].map((c) => c.toLowerCase())
|
|
37
|
+
|
|
38
|
+
it('a controlled, on-tempo rep is silver', () => {
|
|
39
|
+
expect(ghostLineColor(0, 0.1).toLowerCase()).toBe(SILVER.toLowerCase())
|
|
40
|
+
})
|
|
41
|
+
it('a controlled but drifting rep dims toward grey — never a red', () => {
|
|
42
|
+
const drift = ghostLineColor(1, 0.1).toLowerCase()
|
|
43
|
+
expect(drift).not.toBe(SILVER.toLowerCase())
|
|
44
|
+
expect(reds).not.toContain(drift)
|
|
45
|
+
})
|
|
46
|
+
it('null tempo deviation is treated as on-tempo (silver)', () => {
|
|
47
|
+
expect(ghostLineColor(null, 0.1).toLowerCase()).toBe(SILVER.toLowerCase())
|
|
48
|
+
})
|
|
49
|
+
it('at the grind threshold it is light red', () => {
|
|
50
|
+
expect(ghostLineColor(0, GRIND_THRESHOLD).toLowerCase()).toBe(RED_LIGHT.toLowerCase())
|
|
51
|
+
})
|
|
52
|
+
it('the mid-severity collapse is mid red', () => {
|
|
53
|
+
// severity 0.5 ⇒ grind = threshold + 0.5·(1−threshold)
|
|
54
|
+
const grind = GRIND_THRESHOLD + 0.5 * (1 - GRIND_THRESHOLD)
|
|
55
|
+
expect(ghostLineColor(0, grind).toLowerCase()).toBe(RED_MID.toLowerCase())
|
|
56
|
+
})
|
|
57
|
+
it('a full collapse is deep red', () => {
|
|
58
|
+
expect(ghostLineColor(0, 1).toLowerCase()).toBe(RED_DEEP.toLowerCase())
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('auraForVerdict', () => {
|
|
63
|
+
it('maps good/null → productive, form-breakdown → stop, else threshold', () => {
|
|
64
|
+
expect(auraForVerdict(null)).toBe('productive')
|
|
65
|
+
expect(auraForVerdict('good')).toBe('productive')
|
|
66
|
+
expect(auraForVerdict('slowing')).toBe('threshold')
|
|
67
|
+
expect(auraForVerdict('grinding')).toBe('threshold')
|
|
68
|
+
expect(auraForVerdict('form-breakdown')).toBe('stop')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* Shared tokens + pure colour helpers for the live-fatigue family. Everything here
|
|
4
|
+
* is token-sourced (semantic tokens / primitive ramps) — no literal surface hex — so
|
|
5
|
+
* the family inherits the surface-ramp refresh when it lands.
|
|
6
|
+
*/
|
|
7
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
8
|
+
import { primitiveRamps, greyRamp } from '../../../theme/tokens/primitives'
|
|
9
|
+
import type { DimensionTone, FatigueVerdictState, SamplePhase } from './fatigue-model'
|
|
10
|
+
|
|
11
|
+
const t = getSemanticColors('dark')
|
|
12
|
+
|
|
13
|
+
export const FONT_HEAD = '"Space Grotesk", sans-serif'
|
|
14
|
+
export const FONT_UI = '"Nunito Sans", sans-serif'
|
|
15
|
+
export const FONT_MONO = 'monospace'
|
|
16
|
+
|
|
17
|
+
/** Per-dimension tone → semantic status colour (the one language the dots + hero share). */
|
|
18
|
+
export const TONE_COLOR: Record<DimensionTone, string> = {
|
|
19
|
+
ok: t['status-success'],
|
|
20
|
+
warn: t['status-warning'],
|
|
21
|
+
alarm: t['status-error'],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Verdict state → the hero word. */
|
|
25
|
+
export const STATE_LABEL: Record<FatigueVerdictState, string> = {
|
|
26
|
+
good: 'Good',
|
|
27
|
+
slowing: 'Slowing',
|
|
28
|
+
grinding: 'Grinding',
|
|
29
|
+
'form-breakdown': 'Form breaking down',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The phase-identity colours for the ghost-spark phase BAND — the TempoDisplay
|
|
34
|
+
* language: eccentric magenta, concentric cyan, hold and idle in the grey family.
|
|
35
|
+
*
|
|
36
|
+
* The idle grey has to read as BAND MATERIAL (a pause the lifter held), not as a
|
|
37
|
+
* hole in the strip: at grey[300] it sat inside the surface-ramp's own value
|
|
38
|
+
* range (`surface-raised` #31302F) so a short pause between ECC and CON read as a
|
|
39
|
+
* gap. grey[200] clears every content plane while staying quieter than the two
|
|
40
|
+
* saturated phase tones, so the band reads contiguous and the phases still lead.
|
|
41
|
+
*
|
|
42
|
+
* `hold` stays GREY rather than taking a hue of its own. It was briefly amber, which
|
|
43
|
+
* was wrong for a reason worth recording: the phase-pacing tone (`pacingTone`) uses
|
|
44
|
+
* warning-amber for "ahead of target", so an amber phase hue collides with the semantic
|
|
45
|
+
* signal painted on top of it. Phase hues are deliberately non-semantic precisely so
|
|
46
|
+
* pacing can own success/warning/error. Grey also reads more like a hold — a held
|
|
47
|
+
* position is an absence of movement, not an event.
|
|
48
|
+
*
|
|
49
|
+
* Hold and idle are separated by VALUE rather than hue, plus the `HOLD` label and the
|
|
50
|
+
* fact that a hold PACES (it has a prescribed duration) while idle never fills:
|
|
51
|
+
* unfilled hold grey[300] — darker than idle
|
|
52
|
+
* idle grey[200]
|
|
53
|
+
* filled hold grey[100] — brighter than idle
|
|
54
|
+
* so a hold reads apart from dead time at any fill level, including the narrow runs
|
|
55
|
+
* where the label drops out.
|
|
56
|
+
*/
|
|
57
|
+
export const PHASE_AXIS_COLOR: Record<SamplePhase, string> = {
|
|
58
|
+
eccentric: primitiveRamps.magenta[800],
|
|
59
|
+
concentric: primitiveRamps.cyan[800],
|
|
60
|
+
hold: greyRamp[700],
|
|
61
|
+
idle: greyRamp[800],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The UNFILLED base a phase sits at before its pacing fill covers it — the same hue,
|
|
66
|
+
* pulled back. {@link PHASE_AXIS_COLOR} is the FILL, so a phase that runs its full
|
|
67
|
+
* prescribed duration ends up looking exactly as the band did before pacing existed;
|
|
68
|
+
* muting is only ever visible where the lifter is UNDER target.
|
|
69
|
+
*
|
|
70
|
+
* `idle` has no prescribed duration, never fills, and so its base is its colour.
|
|
71
|
+
*/
|
|
72
|
+
export const PHASE_AXIS_BASE_COLOR: Record<SamplePhase, string> = {
|
|
73
|
+
eccentric: primitiveRamps.magenta[950],
|
|
74
|
+
concentric: primitiveRamps.cyan[950],
|
|
75
|
+
hold: greyRamp[900],
|
|
76
|
+
idle: greyRamp[800],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pacing tones for a label sitting INSIDE the band — ahead / on pace / over.
|
|
81
|
+
*
|
|
82
|
+
* Only ECC and CON carry labels, so the backgrounds these must survive are the eccentric
|
|
83
|
+
* and concentric fills and their muted bases. The binding one is the CONCENTRIC FILL
|
|
84
|
+
* (cyan[800]) — a mid-dark blue, so a legible tone on it has to stay fairly light.
|
|
85
|
+
*
|
|
86
|
+
* Ramp step **300** is the DEEPEST step at which all three clear 4.5:1:
|
|
87
|
+
*
|
|
88
|
+
* worst-case contrast over {ecc, con} × {fill, base} (worst is always conFill)
|
|
89
|
+
* red[300] 4.91 red[400] = 3.59 — fails
|
|
90
|
+
* amber[300] 5.48 amber[400] = 3.76 — fails
|
|
91
|
+
* green[300] 5.16 green[400] = 4.15 — fails
|
|
92
|
+
*
|
|
93
|
+
* `over` cannot go deeper than this without losing legibility, which is a property of
|
|
94
|
+
* small light-on-dark text rather than a choice: the 3:1 large-text allowance needs ~18.7px
|
|
95
|
+
* bold and these labels are 8px. `status-error` (red[600]) measures 2.18:1 here.
|
|
96
|
+
*
|
|
97
|
+
* `ahead` and `onPace` land back on exactly the `status-warning` / `status-success` values;
|
|
98
|
+
* only `over` has to deviate from the semantic token.
|
|
99
|
+
*
|
|
100
|
+
* `PACING_TONE_MIN_CONTRAST` is asserted in `tempo-pacing.test.ts`, so a future ramp edit
|
|
101
|
+
* cannot quietly regress this.
|
|
102
|
+
*/
|
|
103
|
+
export const PACING_TONE = {
|
|
104
|
+
ahead: primitiveRamps.amber[300],
|
|
105
|
+
onPace: primitiveRamps.green[300],
|
|
106
|
+
over: primitiveRamps.red[300],
|
|
107
|
+
} as const
|
|
108
|
+
|
|
109
|
+
/** The floor every {@link PACING_TONE} must clear against any band background. */
|
|
110
|
+
export const PACING_TONE_MIN_CONTRAST = 4.5
|
|
111
|
+
|
|
112
|
+
// --- shared silver/red scheme (ROM chart + ghost-spark line) --------------------
|
|
113
|
+
// One source of truth for the two quality readouts: SILVER when the rep is right,
|
|
114
|
+
// SHADES OF RED when there's an issue. No greens, no ambers — those languages belong
|
|
115
|
+
// to the verdict tones / velocity-loss bands, not here.
|
|
116
|
+
/** On-track / at-or-above-working. */
|
|
117
|
+
export const SILVER = greyRamp[200]
|
|
118
|
+
export const RED_LIGHT = primitiveRamps.red[400]
|
|
119
|
+
export const RED_MID = primitiveRamps.red[600]
|
|
120
|
+
export const RED_DEEP = primitiveRamps.red[800]
|
|
121
|
+
|
|
122
|
+
/** Below this grind signature the rep is "controlled" → silver; at/above it goes red. */
|
|
123
|
+
export const GRIND_THRESHOLD = 0.35
|
|
124
|
+
/** A quiet grey the controlled line dims toward as it drifts (never a colour): a dimmed
|
|
125
|
+
* cool grey in the SAME neutral family as SILVER, so a fully-drifted line reads dim-silver
|
|
126
|
+
* rather than sinking toward black. Shared with the ROM chart. */
|
|
127
|
+
export const DRIFT_GREY = greyRamp[700]
|
|
128
|
+
|
|
129
|
+
export function clamp01(v: number): number {
|
|
130
|
+
return Math.min(1, Math.max(0, v))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
134
|
+
const h = hex.replace('#', '')
|
|
135
|
+
const f =
|
|
136
|
+
h.length === 3
|
|
137
|
+
? h
|
|
138
|
+
.split('')
|
|
139
|
+
.map((c) => c + c)
|
|
140
|
+
.join('')
|
|
141
|
+
: h
|
|
142
|
+
return [parseInt(f.slice(0, 2), 16), parseInt(f.slice(2, 4), 16), parseInt(f.slice(4, 6), 16)]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Linear mix of two hex colours (`t` 0..1). Both inputs are token-sourced hex. */
|
|
146
|
+
export function mixHex(a: string, b: string, ratio: number): string {
|
|
147
|
+
const [ar, ag, ab] = hexToRgb(a)
|
|
148
|
+
const [br, bg, bb] = hexToRgb(b)
|
|
149
|
+
const m = (x: number, y: number) => Math.round(x + (y - x) * clamp01(ratio))
|
|
150
|
+
return `#${[m(ar, br), m(ag, bg), m(ab, bb)]
|
|
151
|
+
.map((v) => v.toString(16).padStart(2, '0'))
|
|
152
|
+
.join('')}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The ghost-spark current-line tint from the two control-aware signals — silver/red.
|
|
157
|
+
*
|
|
158
|
+
* A CONTROLLED rep (`grindSignature < GRIND_THRESHOLD`) stays SILVER, dimming a touch
|
|
159
|
+
* toward a quiet grey by `tempoDeviation` — a "drifting but not failing" cue that never
|
|
160
|
+
* becomes a colour. A COLLAPSING rep (at/above the threshold) goes through SHADES OF RED
|
|
161
|
+
* by severity: light → mid → deep as the collapse deepens.
|
|
162
|
+
*/
|
|
163
|
+
export function ghostLineColor(tempoDeviation: number | null, grindSignature: number): string {
|
|
164
|
+
if (grindSignature >= GRIND_THRESHOLD) {
|
|
165
|
+
const severity = clamp01((grindSignature - GRIND_THRESHOLD) / (1 - GRIND_THRESHOLD))
|
|
166
|
+
return severity < 0.5
|
|
167
|
+
? mixHex(RED_LIGHT, RED_MID, severity * 2)
|
|
168
|
+
: mixHex(RED_MID, RED_DEEP, (severity - 0.5) * 2)
|
|
169
|
+
}
|
|
170
|
+
return mixHex(SILVER, DRIFT_GREY, (tempoDeviation ?? 0) * 0.55)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Verdict state → the {@link LiveAuraFrame} coaching-flood category. */
|
|
174
|
+
export function auraForVerdict(
|
|
175
|
+
state: FatigueVerdictState | null
|
|
176
|
+
): 'productive' | 'threshold' | 'stop' {
|
|
177
|
+
if (state == null || state === 'good') return 'productive'
|
|
178
|
+
if (state === 'form-breakdown') return 'stop'
|
|
179
|
+
return 'threshold'
|
|
180
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Live-fatigue component family — the aligned "Live panel v2" hardened into titan
|
|
2
|
+
// components. See ./README.md for the composition tree + reuse audit.
|
|
3
|
+
export { LiveFatiguePanel } from './LiveFatiguePanel'
|
|
4
|
+
export type {
|
|
5
|
+
LiveFatiguePanelProps,
|
|
6
|
+
LiveFatiguePanelHeader,
|
|
7
|
+
LiveFatiguePanelVelocity,
|
|
8
|
+
} from './LiveFatiguePanel'
|
|
9
|
+
export { LiveFatigueCard, type LiveFatigueCardProps } from './LiveFatigueCard'
|
|
10
|
+
export { VerdictHero, type VerdictHeroProps } from './VerdictHero'
|
|
11
|
+
export { FatigueLights, type FatigueLightsProps } from './FatigueLights'
|
|
12
|
+
export { RomProgressionChart, type RomProgressionChartProps } from './RomProgressionChart'
|
|
13
|
+
export { GhostSpark, type GhostSparkProps } from './GhostSpark'
|
|
14
|
+
export { DualGhostSpark, type DualGhostSparkProps, mergePhaseSegments } from './DualGhostSpark'
|
|
15
|
+
export { GhostBand, type GhostBandProps, BAND_H, BAND_GAP } from './GhostBand'
|
|
16
|
+
export { GhostBloom, type GhostBloomProps, type Pt, smoothPath } from './GhostBloom'
|
|
17
|
+
export { VelocityHero, type VelocityHeroProps } from './VelocityHero'
|
|
18
|
+
export {
|
|
19
|
+
ghostLineColor,
|
|
20
|
+
auraForVerdict,
|
|
21
|
+
TONE_COLOR,
|
|
22
|
+
STATE_LABEL,
|
|
23
|
+
GRIND_THRESHOLD,
|
|
24
|
+
SILVER,
|
|
25
|
+
DRIFT_GREY,
|
|
26
|
+
RED_LIGHT,
|
|
27
|
+
RED_MID,
|
|
28
|
+
RED_DEEP,
|
|
29
|
+
PHASE_AXIS_COLOR,
|
|
30
|
+
} from './fatigue-tokens'
|
|
31
|
+
export type {
|
|
32
|
+
LiveFatigueModel,
|
|
33
|
+
FatigueVerdict,
|
|
34
|
+
FatigueVerdictState,
|
|
35
|
+
DimensionTone,
|
|
36
|
+
SamplePhase,
|
|
37
|
+
VelocitySample,
|
|
38
|
+
PhaseSegment,
|
|
39
|
+
RepVelocityCurve,
|
|
40
|
+
RepRomPoint,
|
|
41
|
+
} from './fatigue-model'
|