@titan-design/react-ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/index.d.mts +399 -4
  2. package/dist/index.d.ts +399 -4
  3. package/dist/index.js +2295 -895
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2279 -905
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  8. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +1974 -1740
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +1975 -1741
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +8 -3
  16. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  17. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  18. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  19. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  20. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  21. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  22. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  23. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  24. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  25. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  26. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  27. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  28. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  29. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  30. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  31. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  32. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  33. package/src/components/custom/Fatigue/README.md +93 -0
  34. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  35. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  36. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  37. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  38. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  39. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  40. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  41. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  42. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  43. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  44. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  45. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  46. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  47. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  48. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  49. package/src/components/custom/Fatigue/index.ts +41 -0
  50. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  51. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  52. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  56. package/src/components/custom/Workout/README.md +62 -16
  57. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  58. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  59. package/src/components/custom/Workout/SessionRail.tsx +50 -13
  60. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  61. package/src/components/custom/Workout/SetRow.tsx +9 -2
  62. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  63. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  64. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  65. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  66. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  68. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  69. package/src/components/custom/Workout/index.ts +3 -0
  70. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  71. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  72. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  73. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  74. package/src/components/custom/index.ts +1 -0
  75. package/src/test/raw-color-patterns.test.ts +71 -0
  76. package/src/theme/ColorPalettes.stories.tsx +871 -0
  77. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  78. package/src/theme/bar-paper.ts +59 -0
  79. package/src/theme/color-stories.coverage.test.ts +130 -0
  80. package/src/theme/color-story-kit.tsx +170 -0
  81. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  82. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  83. package/src/theme/ColorSystem.stories.tsx +0 -508
  84. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,86 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * LiveFatigueCard — the vertical live fatigue card. One focal read (the RPE/verdict
4
+ * hero + three why-lights, grouped at top) → the ROM progression → the ghost-spark
5
+ * (tempo embedded), the last two spread through the leftover height. Consumes ONE
6
+ * {@link LiveFatigueModel}.
7
+ *
8
+ * SURFACE (TD-07.08). The card grounds on the `base` plane — ONE step above the
9
+ * `background` shell the live stage paints, per the surface north-star's pairing matrix
10
+ * (a card is parent+1; `raised`/`overlay` skip levels and read hot against the near-black
11
+ * frame). Separation is carried by the alpha `hairline-default` edge, not by lightness —
12
+ * the north-star's primary cue, self-normalising on any plane.
13
+ *
14
+ * PAPER. The live card is one of the designated hero surfaces, so it takes the paper
15
+ * accent DELIBERATELY: the shared {@link barPaper} treatment (brightness-scaled matte
16
+ * grain + crisp top rim-light + soft contact shadow) — the same material the ROM /
17
+ * velocity bars are made of, now carrying the sheet they sit on. Token-sourced fill, no
18
+ * hardcoded surface hex.
19
+ */
20
+ import { View } from 'react-native'
21
+ import { Surface } from '../../ui/surface/Surface'
22
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
23
+ import { barPaper } from '../../../theme/bar-paper'
24
+ import { VerdictHero } from './VerdictHero'
25
+ import { FatigueLights } from './FatigueLights'
26
+ import { RomProgressionChart } from './RomProgressionChart'
27
+ import { GhostSpark } from './GhostSpark'
28
+ import type { LiveFatigueModel } from './fatigue-model'
29
+
30
+ const t = getSemanticColors('dark')
31
+
32
+ export interface LiveFatigueCardProps {
33
+ /** The live fatigue read-model for the current set. */
34
+ model: LiveFatigueModel
35
+ /** Card width in px. Default 318. */
36
+ width?: number
37
+ /** Card height in px — when set, the sections spread through the leftover height. */
38
+ height?: number
39
+ }
40
+
41
+ const PAD = 18
42
+ const GHOST_GUTTER = 4 // GhostSpark carries this L/R padding internally
43
+
44
+ export function LiveFatigueCard({ model, width = 318, height }: LiveFatigueCardProps) {
45
+ const chartW = width - PAD * 2 - GHOST_GUTTER * 2
46
+ const chartH = height != null ? Math.round(Math.min(240, Math.max(168, height * 0.4))) : 172
47
+ return (
48
+ <Surface
49
+ level="base"
50
+ testID="live-fatigue-card"
51
+ style={{
52
+ width,
53
+ height,
54
+ borderRadius: 14,
55
+ padding: PAD,
56
+ borderWidth: 1,
57
+ borderColor: t['hairline-default'],
58
+ ...barPaper(t['surface-base']),
59
+ }}
60
+ >
61
+ {/* top group — verdict hero + the three why-lights, tight together. */}
62
+ <View style={{ gap: 12 }}>
63
+ <VerdictHero rpe={model.rpe} verdict={model.verdict} />
64
+ <FatigueLights dimensions={model.verdict?.dimensions ?? null} />
65
+ </View>
66
+
67
+ <View style={{ flex: 1, minHeight: 16 }} />
68
+
69
+ <RomProgressionChart
70
+ points={model.romProgression}
71
+ workingStandardM={model.romWorkingStandardM}
72
+ shortThresholdM={model.romShortThresholdM}
73
+ plannedReps={model.plannedReps}
74
+ />
75
+
76
+ <View style={{ flex: 1, minHeight: 16 }} />
77
+
78
+ <GhostSpark
79
+ curves={model.velocityCurves}
80
+ width={chartW}
81
+ height={chartH}
82
+ targetTempoSeconds={model.targetTempoSeconds}
83
+ />
84
+ </Surface>
85
+ )
86
+ }
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { LiveFatiguePanel } from './LiveFatiguePanel'
4
+ import { primitiveColors } from '../../../theme/tokens/primitives'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+ import { FATIGUE_STATES, buildMockPanelState } from './fatigue-mock'
7
+
8
+ const PAGE_BG = primitiveColors.charcoal[900]
9
+ const t = getSemanticColors('dark')
10
+
11
+ const meta: Meta<typeof LiveFatiguePanel> = {
12
+ title: 'Workout/Fatigue/Live Fatigue Panel',
13
+ component: LiveFatiguePanel,
14
+ tags: ['autodocs'],
15
+ parameters: {
16
+ layout: 'fullscreen',
17
+ docs: {
18
+ description: {
19
+ component:
20
+ 'The aligned **Live panel v2** — the primary loss-relative `VelocityHero` beside the vertical ' +
21
+ '`LiveFatigueCard`, flooded by a `LiveAuraFrame` whose category tracks the verdict state. ' +
22
+ 'Composes `VelocityHero` (VelocityStrip + VL bands) · `LiveFatigueCard` (VerdictHero · ' +
23
+ 'FatigueLights · RomProgressionChart · GhostSpark) · `LiveAuraFrame`. The velocity hero data ' +
24
+ 'is passed separately (`velocity`) — it is not on the fatigue model.',
25
+ },
26
+ },
27
+ },
28
+ }
29
+ export default meta
30
+ type Story = StoryObj<typeof LiveFatiguePanel>
31
+
32
+ /** THE aligned direction — a breaking-down rep 8, ghost-spark revealed. */
33
+ export const LivePanelV2: Story = {
34
+ name: 'Live panel v2 (composition)',
35
+ render: () => {
36
+ const { model, velocity, header } = buildMockPanelState(FATIGUE_STATES[3].current, {
37
+ rpe: 10,
38
+ verdict: FATIGUE_STATES[3].model.verdict,
39
+ })
40
+ return (
41
+ <View style={{ backgroundColor: PAGE_BG, padding: 24 }}>
42
+ <LiveFatiguePanel model={model} velocity={velocity} header={header} />
43
+ </View>
44
+ )
45
+ },
46
+ }
47
+
48
+ /** The whole system across the verdict spectrum — GOOD → SLOWING → GRINDING → FORM BREAKING DOWN. */
49
+ export const LiveStates: Story = {
50
+ name: 'Live states (Good → Breaking down)',
51
+ render: () => (
52
+ <View style={{ backgroundColor: PAGE_BG }}>
53
+ <View style={{ padding: 28, paddingBottom: 8, gap: 4 }}>
54
+ <Text
55
+ style={{
56
+ fontSize: 16,
57
+ fontWeight: '800',
58
+ fontFamily: '"Space Grotesk", sans-serif',
59
+ color: t['text-primary'],
60
+ }}
61
+ >
62
+ Live-view states — the system across the fatigue spectrum
63
+ </Text>
64
+ <Text style={{ fontSize: 12, color: t['text-secondary'], maxWidth: 860, lineHeight: 18 }}>
65
+ The live panel rendered per verdict state, each driven by a full plausible model — RPE,
66
+ the three status dots, the control-aware ghost line, and the ROM progression all respond
67
+ together. The aura flood tracks the state. Every panel is built from ONE truncation point,
68
+ so the ROM chart&apos;s upcoming reps and the velocity strip&apos;s beside it always agree
69
+ on the rep count.
70
+ </Text>
71
+ </View>
72
+ {FATIGUE_STATES.map((s) => {
73
+ const { model, velocity, header } = buildMockPanelState(s.current, {
74
+ rpe: s.model.rpe,
75
+ verdict: s.model.verdict,
76
+ })
77
+ return (
78
+ <View key={s.name} style={{ gap: 6, paddingHorizontal: 28, paddingBottom: 22 }}>
79
+ <Text
80
+ style={{
81
+ fontSize: 9,
82
+ letterSpacing: 1,
83
+ fontFamily: 'monospace',
84
+ color: t['text-tertiary'],
85
+ }}
86
+ >
87
+ {s.name}
88
+ </Text>
89
+ <LiveFatiguePanel model={model} velocity={velocity} header={header} />
90
+ </View>
91
+ )
92
+ })}
93
+ </View>
94
+ ),
95
+ }
@@ -0,0 +1,151 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * LiveFatiguePanel — the aligned "Live panel v2": the loss-relative {@link VelocityHero}
4
+ * (primary) beside the vertical {@link LiveFatigueCard} (secondary), flooded by a
5
+ * coaching {@link LiveAuraFrame} whose category tracks the verdict state. The optional
6
+ * lite exercise header sits above.
7
+ *
8
+ * The velocity hero's per-rep velocities are NOT on the fatigue model (they come from
9
+ * the live-view velocity path), so they're passed as their own `velocity` prop.
10
+ */
11
+ import { View, Text } from 'react-native'
12
+ import { LiveAuraFrame, type LiveAuraCategory } from '../Workout/LiveAuraFrame'
13
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
14
+ import { alpha } from '../../../utils/colors'
15
+ import { VelocityHero } from './VelocityHero'
16
+ import { LiveFatigueCard } from './LiveFatigueCard'
17
+ import { FONT_HEAD, FONT_UI, FONT_MONO, auraForVerdict } from './fatigue-tokens'
18
+ import type { LiveFatigueModel } from './fatigue-model'
19
+
20
+ const t = getSemanticColors('dark')
21
+
22
+ export interface LiveFatiguePanelHeader {
23
+ /** Exercise name — the large title. */
24
+ title: string
25
+ /** Prescription / block line under the title. */
26
+ subtitle?: string
27
+ /** Right-aligned progress meta, e.g. "SET 3 · REP 7 / 8". */
28
+ meta?: string
29
+ }
30
+
31
+ export interface LiveFatiguePanelVelocity {
32
+ /** Per-rep MEAN concentric velocity (m/s), ordered by rep. */
33
+ velocities: number[]
34
+ targetReps?: number
35
+ liveRepIndex?: number
36
+ }
37
+
38
+ export interface LiveFatiguePanelProps {
39
+ /** The live fatigue read-model for the current set. */
40
+ model: LiveFatigueModel
41
+ /** The velocity-hero data (its own source — not on the fatigue model). */
42
+ velocity: LiveFatiguePanelVelocity
43
+ /** Optional lite exercise header. Omitted → no header row. */
44
+ header?: LiveFatiguePanelHeader
45
+ /** Aura-flood category. Defaults to the verdict-derived category. */
46
+ aura?: LiveAuraCategory
47
+ /** Panel body height in px. Default 508. */
48
+ bodyHeight?: number
49
+ /** Fixed fatigue-card column width. Default 318. */
50
+ cardWidth?: number
51
+ }
52
+
53
+ function ExerciseHeaderLite({ header }: { header: LiveFatiguePanelHeader }) {
54
+ return (
55
+ <View
56
+ style={{
57
+ flexDirection: 'row',
58
+ alignItems: 'flex-end',
59
+ justifyContent: 'space-between',
60
+ paddingHorizontal: 24,
61
+ paddingTop: 18,
62
+ paddingBottom: 14,
63
+ borderBottomWidth: 1,
64
+ borderColor: alpha(t['text-primary'], 0.08),
65
+ }}
66
+ >
67
+ <View style={{ gap: 2 }}>
68
+ <Text
69
+ style={{
70
+ fontSize: 28,
71
+ fontWeight: '700',
72
+ fontFamily: FONT_HEAD,
73
+ color: t['text-primary'],
74
+ }}
75
+ >
76
+ {header.title}
77
+ </Text>
78
+ {header.subtitle && (
79
+ <Text
80
+ style={{
81
+ fontSize: 13,
82
+ fontWeight: '600',
83
+ fontFamily: FONT_UI,
84
+ color: t['text-secondary'],
85
+ }}
86
+ >
87
+ {header.subtitle}
88
+ </Text>
89
+ )}
90
+ </View>
91
+ {header.meta && (
92
+ <Text
93
+ style={{
94
+ fontSize: 13,
95
+ fontWeight: '800',
96
+ fontFamily: FONT_MONO,
97
+ color: t['text-tertiary'],
98
+ }}
99
+ >
100
+ {header.meta}
101
+ </Text>
102
+ )}
103
+ </View>
104
+ )
105
+ }
106
+
107
+ export function LiveFatiguePanel({
108
+ model,
109
+ velocity,
110
+ header,
111
+ aura,
112
+ bodyHeight = 508,
113
+ cardWidth = 318,
114
+ }: LiveFatiguePanelProps) {
115
+ const category = aura ?? auraForVerdict(model.verdict?.state ?? null)
116
+ const heroH = bodyHeight - 26 // leaves room for the hero's own eyebrow above it
117
+ return (
118
+ <LiveAuraFrame
119
+ category={category}
120
+ style={{ borderRadius: 0, borderWidth: 0 }}
121
+ testID="live-fatigue-panel"
122
+ >
123
+ <View style={{ flex: 1 }}>
124
+ {header && <ExerciseHeaderLite header={header} />}
125
+ <View style={{ padding: 24, flexDirection: 'row', gap: 18, alignItems: 'stretch' }}>
126
+ {/* PRIMARY — the velocity hero with VL bands; flexes to fill the width the card leaves. */}
127
+ <View style={{ flex: 1, gap: 8 }}>
128
+ <Text
129
+ style={{
130
+ fontSize: 9,
131
+ letterSpacing: 1.2,
132
+ fontFamily: FONT_MONO,
133
+ color: t['text-tertiary'],
134
+ }}
135
+ >
136
+ VELOCITY · this set
137
+ </Text>
138
+ <VelocityHero
139
+ velocities={velocity.velocities}
140
+ targetReps={velocity.targetReps}
141
+ liveRepIndex={velocity.liveRepIndex}
142
+ height={heroH}
143
+ />
144
+ </View>
145
+ {/* SECONDARY — the vertical fatigue card. */}
146
+ <LiveFatigueCard model={model} width={cardWidth} height={bodyHeight} />
147
+ </View>
148
+ </View>
149
+ </LiveAuraFrame>
150
+ )
151
+ }
@@ -0,0 +1,93 @@
1
+ # Fatigue component family
2
+
3
+ The aligned **"Live panel v2"** hardened into titan components: the live velocity
4
+ hero beside the always-on fatigue card. Everything consumes one read-model
5
+ (`LiveFatigueModel`) — a titan-local mirror of voltras-mcp's
6
+ `src/dashboard/spa/live-page/fatigue-model.ts`, sourced from real WA analytics
7
+ (`getSetFatigueVerdict` / `getSetWorkingROM` / per-sample telemetry). Files live flat;
8
+ the tier map below is a documentation contract, not a directory layout.
9
+
10
+ ## Composition tree (composes-↓ / used-by-↑)
11
+
12
+ ```
13
+ LiveFatiguePanel ← the composition (Live panel v2)
14
+ ├─ LiveAuraFrame (Workout/ — coaching flood, category tracks the verdict)
15
+ ├─ VelocityHero ← primary read
16
+ │ └─ VelocityStrip (Workout/ — reused as-is; VL bands overlaid on its peak scale)
17
+ └─ LiveFatigueCard ← secondary read (consumes LiveFatigueModel)
18
+ ├─ VerdictHero (RPE number + verdict word, tone-flooded)
19
+ ├─ FatigueLights (VEL/ROM/TEMPO "why" dots)
20
+ │ ├─ StatusDot (Workout/ — the dot primitive, glow)
21
+ │ └─ Tooltip (ui/tooltip — hover detail)
22
+ ├─ RomProgressionChart (per-rep silver/red depth bars + reference lines)
23
+ ├─ GhostSpark (per-rep velocity-time sparkline; tempo EMBEDDED)
24
+ │ ├─ GhostBand (the phase-coloured axis band — ECC/CON labelled inside)
25
+ │ └─ GhostBloom (the ghost fan + silver→red current line; orientation up/down)
26
+ ├─ DualGhostSpark (dual-Voltra: ONE GhostBand + TWO mirrored GhostBlooms)
27
+ │ ├─ GhostBand (the SAME band, centred, shared by both devices)
28
+ │ └─ GhostBloom ×2 (left up / right down — a one-prop flip, one shared scale)
29
+ └─ Surface (ui/surface — the base-plane, paper-accented card ground)
30
+ ```
31
+
32
+ ## Tier map
33
+
34
+ **Atoms** — `VerdictHero` · `FatigueLights` · `RomProgressionChart` · `GhostBand` · `GhostBloom`
35
+ **Molecules** — `VelocityHero` (VelocityStrip + VL bands) · `GhostSpark` (band + one bloom) ·
36
+ `DualGhostSpark` (one band + two mirrored blooms)
37
+ **Organisms** — `LiveFatigueCard` (the card, one data contract) · `LiveFatiguePanel` (hero + card + aura)
38
+
39
+ ## Reuse audit — composed, not hand-rolled
40
+
41
+ - **`StatusDot`** (Workout/) — the fatigue lights are `StatusDot` (glow) + a mono label
42
+ inside a `Tooltip`; no re-implemented dot.
43
+ - **`Tooltip`** (ui/tooltip/) — the per-dimension hover detail.
44
+ - **`VelocityStrip`** (Workout/) — the hero reuses it verbatim; `VelocityHero` only adds
45
+ the loss-relative VL20/VL30 band overlay on the strip's own peak scale.
46
+ - **`LiveAuraFrame`** (Workout/) — the coaching flood.
47
+ - **`Surface`** (ui/surface/) — the card ground (`level="base"`, one step above the
48
+ `background` shell), separated by the alpha `hairline-default` edge and finished with
49
+ the shared `barPaper` accent; no hardcoded surface hex.
50
+ - **`GhostBand` / `GhostBloom`** — the single `GhostSpark` and the dual `DualGhostSpark`
51
+ compose the SAME band + bloom; the dual is the bloom with `orientation="down"`, not a
52
+ second renderer. No forked path / band / tint code, so a bloom improvement reaches both.
53
+ - **Tokens** — colours come from `getSemanticColors` / `primitiveRamps`; formatting from
54
+ `roundTempo`. No literal surface/status hex constants.
55
+
56
+ **No new top-level primitives promoted.** Every genuinely-new leaf here (verdict hero,
57
+ lights, ROM chart, ghost-spark) is fatigue-specific with a single consumer (the card),
58
+ so per the ≥2-consumer rule they stay in the family rather than becoming shared atoms.
59
+ The reusable pure helpers (`ghostLineColor`, `auraForVerdict`, `mixHex`) live in
60
+ `fatigue-tokens.ts`.
61
+
62
+ ## Locked design calls (applied)
63
+
64
+ - **Velocity hero = loss-relative** — VL20/VL30 decision bands (not absolute velocity
65
+ zones). See _deferred_ below for the bar-fill recolour.
66
+ - **ROM chart + ghost line = ONE silver/red scheme** — silver when right, only SHADES OF
67
+ RED when there's an issue (no greens, no ambers). Shared constants `SILVER` /
68
+ `RED_LIGHT|MID|DEEP` live in `fatigue-tokens.ts`; both consumers import them. The ghost
69
+ line (`ghostLineColor`): controlled rep stays silver, dimming toward grey with
70
+ `tempoDeviation` (a drift cue, never a colour); a collapsing rep (`grindSignature ≥ 0.35`)
71
+ runs light→mid→deep red by severity. ROM bars: silver at/above working, light red below
72
+ working, deep red below the short threshold. (The verdict tones and the velocity-loss
73
+ VL20/VL30 bands are a SEPARATE language — unchanged.)
74
+ - **RPE only** — no reps-in-reserve line; the number + verdict word carry exertion state.
75
+ - **No separate top alert** — the card covers exertion state.
76
+ - **Tempo carried by the ghost-spark phase BAND** (`PHASE_AXIS_COLOR`) — no mini-tempo
77
+ digits, and no standalone hero-tempo component.
78
+
79
+ ## Data plan / follow-ups
80
+
81
+ - **Store wiring:** `LiveFatigueCard` consumes `LiveFatigueModel` from voltras-mcp's
82
+ `mapStoreToFatigueModel` (`panels/fatigue-view.ts`), replacing the provisional
83
+ hand-rolled `live-page/FatigueCard.tsx` spike. The velocity hero's per-rep velocities
84
+ are NOT on the model (they come from the live-view velocity path) — passed as the
85
+ panel's separate `velocity` prop.
86
+ - **Deferred — VelocityStrip loss-relative bar fill:** the locked call also wants the
87
+ hero's BAR FILL recoloured gold→orange→red by velocity loss (not absolute zone).
88
+ VelocityStrip has no loss-relative colour mode; `VelocityHero` owns the band overlay
89
+ only. Recolouring needs a new `VelocityStrip` prop (a follow-up — not done here to keep
90
+ VelocityStrip reused as-is).
91
+ - **Deferred — velocity bar "grow up from bottom" animation:** the locked call wants the
92
+ live bar to grow from the baseline (tracking the rep) rather than the current
93
+ drop-in/pop. That is VelocityStrip-internal animation; a follow-up on VelocityStrip.
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { RomProgressionChart } from './RomProgressionChart'
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 RomProgressionChart> = {
12
+ title: 'Workout/Fatigue/ROM Progression',
13
+ component: RomProgressionChart,
14
+ tags: ['autodocs'],
15
+ parameters: {
16
+ docs: {
17
+ description: {
18
+ component:
19
+ 'Per-rep depth bars (silver at/above working → light red below working range → deep red below the short threshold), ' +
20
+ 'with dashed working-standard + short-threshold reference lines and a faint red short-zone. ' +
21
+ 'Data-driven from absolute metres; the bars carry the read alone (no caption strip). ' +
22
+ 'Composes the shared **SetBarChart** — same adaptive bar spacing as the velocity hero, and an optional ' +
23
+ '`plannedReps` draws the remaining reps as dashed to-do placeholders (the "N of M done" read).',
24
+ },
25
+ },
26
+ },
27
+ argTypes: { barHeight: { control: { type: 'number', min: 24, max: 120, step: 2 } } },
28
+ }
29
+ export default meta
30
+ type Story = StoryObj<typeof RomProgressionChart>
31
+
32
+ /**
33
+ * Mid-set with `plannedReps` — 4 of 10 done, the remaining 6 reps drawn as dashed to-do placeholders
34
+ * (the velocity hero's "N of M" read, now on the ROM chart). The working-standard + short-threshold
35
+ * lines still sit on-chart. Bars carry the shared SetBarChart adaptive spacing.
36
+ */
37
+ export const PlannedToDo: Story = {
38
+ render: () => (
39
+ <View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 22 }}>
40
+ {[
41
+ { name: 'MID-SET · 4 OF 10', done: 4 },
42
+ { name: 'FIRST REP · 1 OF 10', done: 1 },
43
+ { name: 'PLANNED · 0 OF 10', done: 0 },
44
+ ].map((s) => (
45
+ <View key={s.name} style={{ gap: 6, width: 320 }}>
46
+ <Text
47
+ style={{
48
+ fontSize: 9,
49
+ letterSpacing: 1,
50
+ fontFamily: 'monospace',
51
+ color: t['text-tertiary'],
52
+ }}
53
+ >
54
+ {s.name}
55
+ </Text>
56
+ <RomProgressionChart
57
+ points={[0.92, 0.9, 0.86, 0.81, 0.77, 0.72]
58
+ .slice(0, s.done)
59
+ .map((romM, i) => ({ repNumber: i + 1, romM }))}
60
+ workingStandardM={0.88}
61
+ shortThresholdM={0.66}
62
+ plannedReps={10}
63
+ />
64
+ </View>
65
+ ))}
66
+ </View>
67
+ ),
68
+ }
69
+
70
+ export const AcrossStates: Story = {
71
+ render: () => (
72
+ <View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 22 }}>
73
+ {[...FATIGUE_STATES, { name: 'WARMING UP', current: 0, model: WARMING_UP_MODEL }].map((s) => (
74
+ <View key={s.name} style={{ gap: 6, width: 300 }}>
75
+ <Text
76
+ style={{
77
+ fontSize: 9,
78
+ letterSpacing: 1,
79
+ fontFamily: 'monospace',
80
+ color: t['text-tertiary'],
81
+ }}
82
+ >
83
+ {s.name}
84
+ </Text>
85
+ <RomProgressionChart
86
+ points={s.model.romProgression}
87
+ workingStandardM={s.model.romWorkingStandardM}
88
+ shortThresholdM={s.model.romShortThresholdM}
89
+ />
90
+ </View>
91
+ ))}
92
+ </View>
93
+ ),
94
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { RomProgressionChart } from './RomProgressionChart'
5
+ import type { RepRomPoint } from './fatigue-model'
6
+
7
+ const points: RepRomPoint[] = [
8
+ { repNumber: 1, romM: 0.9 },
9
+ { repNumber: 2, romM: 0.88 },
10
+ { repNumber: 3, romM: 0.72 },
11
+ ]
12
+
13
+ describe('RomProgressionChart', () => {
14
+ it('renders no caption strip — the bars carry the read alone', () => {
15
+ render(<RomProgressionChart points={points} workingStandardM={0.88} shortThresholdM={0.66} />)
16
+ expect(screen.queryByText(/depth vs working range/)).not.toBeInTheDocument()
17
+ expect(screen.queryByText(/^now /)).not.toBeInTheDocument()
18
+ })
19
+
20
+ it('renders the chart container', () => {
21
+ render(<RomProgressionChart points={points} workingStandardM={0.88} shortThresholdM={0.66} />)
22
+ expect(screen.getByTestId('rom-progression')).toBeInTheDocument()
23
+ })
24
+
25
+ it('has no accessibility violations', async () => {
26
+ const { container } = render(
27
+ <RomProgressionChart points={points} workingStandardM={0.88} shortThresholdM={0.66} />
28
+ )
29
+ expect(await axe(container)).toHaveNoViolations()
30
+ })
31
+ })
@@ -0,0 +1,132 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * RomProgressionChart — the per-rep depth progression on the shared silver/red scheme:
4
+ * silver bars at-or-above the working range, light red below it, deep red below the
5
+ * short threshold — with dashed working-standard (silver) + short-threshold (red)
6
+ * reference lines and a faint red short-zone. Every rep reads at full colour.
7
+ * The bars carry the whole read — no caption strip (the card's own hierarchy names it).
8
+ *
9
+ * Composes the shared {@link SetBarChart}: SetBarChart owns the geometry (value→height
10
+ * scaling, the plot-width-driven adaptive bar width / gap — the same rhythm as the velocity
11
+ * hero) and paints the reference overlay this chart hands it. When `plannedReps` exceeds the
12
+ * performed count the remaining reps draw as SetBarChart's default solid to-do sections — the
13
+ * same upcoming-rep treatment the velocity strip uses, so "3 of 8 done" reads identically in
14
+ * both charts. Bars scale to the tallest of {bars, working standard, short threshold} so the
15
+ * reference lines always sit on-chart; when no working standard is established yet (< 3 reps)
16
+ * the lines drop and every bar reads silver — the bars alone carry the shape.
17
+ */
18
+ import { View } from 'react-native'
19
+ import { alpha } from '../../../utils/colors'
20
+ import { SetBarChart, type SetSlot, type SetBarGeometry } from '../charts/SetBarChart'
21
+ import { SILVER, RED_LIGHT, RED_DEEP } from './fatigue-tokens'
22
+ import type { RepRomPoint } from './fatigue-model'
23
+
24
+ export interface RomProgressionChartProps {
25
+ /** Per-rep ROM points (metres), ordered by rep. */
26
+ points: RepRomPoint[]
27
+ /** Working-range standard (metres) — the silver dashed line. `null` = not yet established. */
28
+ workingStandardM: number | null
29
+ /** Short threshold (metres) — the red dashed line + short-zone. `null` = not yet established. */
30
+ shortThresholdM: number | null
31
+ /** Height of the bar plot in px. Default 44. */
32
+ barHeight?: number
33
+ /** Keep the chart to a legible recent tail. Default 12. */
34
+ maxReps?: number
35
+ /**
36
+ * Planned rep count. Reps beyond the performed count draw as solid to-do sections — the
37
+ * velocity strip's "3 of 8 done" read. Ignored when absent or ≤ the performed-rep count.
38
+ */
39
+ plannedReps?: number
40
+ }
41
+
42
+ /** A per-rep bar's colour (silver/red): deep red below short, light red below working, else silver. */
43
+ function barColor(romM: number, working: number | null, short: number | null): string {
44
+ if (short != null && romM < short) return RED_DEEP
45
+ if (working != null && romM < working) return RED_LIGHT
46
+ return SILVER
47
+ }
48
+
49
+ /** The dashed working-standard + short-threshold lines and the faint short-zone fill, painted from chart geometry. */
50
+ function romReferenceOverlay(
51
+ g: SetBarGeometry,
52
+ workingStandardM: number | null,
53
+ shortThresholdM: number | null
54
+ ) {
55
+ return (
56
+ <>
57
+ {shortThresholdM != null && (
58
+ <View
59
+ pointerEvents="none"
60
+ style={{
61
+ position: 'absolute',
62
+ left: 0,
63
+ right: 0,
64
+ bottom: 0,
65
+ height: g.yOf(shortThresholdM),
66
+ backgroundColor: alpha(RED_DEEP, 0.06),
67
+ }}
68
+ />
69
+ )}
70
+ {workingStandardM != null && (
71
+ <View
72
+ pointerEvents="none"
73
+ style={{
74
+ position: 'absolute',
75
+ left: 0,
76
+ right: 0,
77
+ bottom: g.yOf(workingStandardM),
78
+ borderTopWidth: 1,
79
+ borderStyle: 'dashed',
80
+ borderColor: alpha(SILVER, 0.4),
81
+ }}
82
+ />
83
+ )}
84
+ {shortThresholdM != null && (
85
+ <View
86
+ pointerEvents="none"
87
+ style={{
88
+ position: 'absolute',
89
+ left: 0,
90
+ right: 0,
91
+ bottom: g.yOf(shortThresholdM),
92
+ borderTopWidth: 1,
93
+ borderStyle: 'dashed',
94
+ borderColor: alpha(RED_DEEP, 0.4),
95
+ }}
96
+ />
97
+ )}
98
+ </>
99
+ )
100
+ }
101
+
102
+ export function RomProgressionChart({
103
+ points,
104
+ workingStandardM,
105
+ shortThresholdM,
106
+ barHeight = 44,
107
+ maxReps = 12,
108
+ plannedReps,
109
+ }: RomProgressionChartProps) {
110
+ const shown = points.slice(-maxReps)
111
+ const slots: SetSlot[] = shown.map((p) => ({ kind: 'rep', value: p.romM }))
112
+ // Scale to the tallest of {bars, working, short} so the reference lines always sit on-chart
113
+ // (SetBarChart's own peak scale reads only the bar values). `0` → let SetBarChart use peak.
114
+ const scaleMax =
115
+ Math.max(...shown.map((p) => p.romM), workingStandardM ?? 0, shortThresholdM ?? 0) || undefined
116
+
117
+ return (
118
+ <View testID="rom-progression">
119
+ <SetBarChart
120
+ slots={slots}
121
+ colorFor={(romM) => barColor(romM, workingStandardM, shortThresholdM)}
122
+ height={barHeight}
123
+ scaleMax={scaleMax}
124
+ barRadius={2}
125
+ hideBaseline
126
+ targetReps={plannedReps}
127
+ renderReference={(g) => romReferenceOverlay(g, workingStandardM, shortThresholdM)}
128
+ testIDPrefix="rom"
129
+ />
130
+ </View>
131
+ )
132
+ }