@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.
Files changed (120) hide show
  1. package/dist/bodymap.js +78 -24
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +78 -24
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +429 -11
  6. package/dist/index.d.ts +429 -11
  7. package/dist/index.js +2386 -885
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2306 -836
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  12. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1900 -1609
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1901 -1610
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +129 -34
  20. package/dist/theme/index.d.ts +129 -34
  21. package/dist/theme/index.js +95 -19
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +92 -20
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +88 -24
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +88 -24
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  31. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  32. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  33. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  34. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  35. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  36. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  37. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  38. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  39. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  40. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  41. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  42. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  43. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  45. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  46. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  47. package/src/components/custom/Fatigue/README.md +93 -0
  48. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  49. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  51. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  52. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  53. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  54. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  55. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  56. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  57. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  58. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  59. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  60. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  61. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  62. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  63. package/src/components/custom/Fatigue/index.ts +41 -0
  64. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  65. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  66. package/src/components/custom/Workout/BaseBadge.tsx +5 -5
  67. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  68. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  69. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  71. package/src/components/custom/Workout/README.md +62 -16
  72. package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
  73. package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
  74. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  75. package/src/components/custom/Workout/SessionHeader.tsx +2 -2
  76. package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
  77. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  78. package/src/components/custom/Workout/SessionRail.tsx +55 -18
  79. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  80. package/src/components/custom/Workout/SetRow.tsx +9 -2
  81. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
  82. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
  83. package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
  84. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  85. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  86. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  87. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  88. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  89. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  90. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  91. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
  92. package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
  93. package/src/components/custom/Workout/index.ts +3 -0
  94. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  95. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  96. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  97. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  98. package/src/components/custom/index.ts +1 -0
  99. package/src/components/ui/surface/Surface.stories.tsx +195 -1
  100. package/src/components/ui/surface/Surface.test.tsx +148 -9
  101. package/src/components/ui/surface/Surface.tsx +37 -9
  102. package/src/components/ui/surface/SurfaceContext.ts +48 -8
  103. package/src/components/ui/surface/index.ts +1 -0
  104. package/src/components/ui/surface/surface.contract.test.ts +283 -0
  105. package/src/test/raw-color-patterns.test.ts +71 -0
  106. package/src/theme/ColorPalettes.stories.tsx +871 -0
  107. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  108. package/src/theme/bar-paper.ts +59 -0
  109. package/src/theme/color-stories.coverage.test.ts +130 -0
  110. package/src/theme/color-story-kit.tsx +170 -0
  111. package/src/theme/config.ts +12 -0
  112. package/src/theme/elevation.ts +90 -67
  113. package/src/theme/global.css +35 -9
  114. package/src/theme/tokens/primitives.ts +40 -0
  115. package/src/theme/tokens/semantic.ts +63 -14
  116. package/tailwind.config.js +8 -0
  117. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  118. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  119. package/src/theme/ColorSystem.stories.tsx +0 -323
  120. package/src/theme/Colors.stories.tsx +0 -323
@@ -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 { primitiveColors, primitiveRamps } 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 charcoal[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. charcoal[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 charcoal[300] — darker than idle
52
+ * idle charcoal[200]
53
+ * filled hold charcoal[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: primitiveColors.charcoal[100],
61
+ idle: primitiveColors.charcoal[200],
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: primitiveColors.charcoal[300],
76
+ idle: primitiveColors.charcoal[200],
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 = primitiveColors.neutral[300]
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 = primitiveColors.neutral[600]
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'