@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,132 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * GhostBloom — the smoothPath velocity "bloom": the faded ghost fan of prior reps and the
4
+ * control-aware silver→red current-rep line, over a paper-inspired line treatment.
5
+ * Extracted from GhostSpark so the single sparkline and the mirrored dual compose the SAME
6
+ * bloom — the dual is just two blooms sharing one axis, one grown UP and one flipped to grow
7
+ * DOWN (the ghost analogue of the diverging VelocityStrip's orientation flip). Improve the
8
+ * bloom once and both single + dual inherit it.
9
+ *
10
+ * The caller owns the x-scale and passes MAGNITUDES (px height off the axis, ≥ 0); the bloom
11
+ * owns the `baseline` (axis y) and `orientation` (grow up or down), so a mirrored twin is a
12
+ * one-prop flip, not a second component.
13
+ */
14
+ import { useId } from 'react'
15
+ import { primitiveColors } from '../../../theme/tokens/primitives'
16
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
17
+ import { alpha } from '../../../utils/colors'
18
+
19
+ const t = getSemanticColors('dark')
20
+ const PARCH = t['text-primary']
21
+
22
+ /** A point as `[x_px, magnitude_px]` — magnitude is height off the axis baseline (≥ 0). */
23
+ export type Pt = [number, number]
24
+
25
+ /** Catmull-Rom → cubic-bezier smoothing over absolute `[x, y]` points. */
26
+ export function smoothPath(pts: Pt[]): string {
27
+ if (pts.length < 2) return pts.length ? `M ${pts[0][0]} ${pts[0][1]}` : ''
28
+ let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`
29
+ for (let i = 0; i < pts.length - 1; i++) {
30
+ const p0 = pts[i - 1] ?? pts[i]
31
+ const p1 = pts[i]
32
+ const p2 = pts[i + 1]
33
+ const p3 = pts[i + 2] ?? p2
34
+ const c1x = p1[0] + (p2[0] - p0[0]) / 6
35
+ const c1y = p1[1] + (p2[1] - p0[1]) / 6
36
+ const c2x = p2[0] - (p3[0] - p1[0]) / 6
37
+ const c2y = p2[1] - (p3[1] - p1[1]) / 6
38
+ d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`
39
+ }
40
+ return d
41
+ }
42
+
43
+ /**
44
+ * The line's ground treatment: a soft, blurred, low-opacity dark shadow with no hard edge —
45
+ * the bar "paper" language adapted to a chart LINE, never a hard black outline.
46
+ *
47
+ * `glow` (a halo in the line's own tint) and `rimlight` (a lighter upper edge plus a dropped
48
+ * contact shadow) were explored alongside it and NOT chosen. They were removed rather than
49
+ * left as unreachable options: no component ever exposed the choice, so the only thing that
50
+ * could select them was the exploration story, and dead branches in a render path read as
51
+ * intent that no longer exists.
52
+ */
53
+ const BLUR_STD_DEV = 2.6
54
+
55
+ export interface GhostBloomProps {
56
+ /** The current rep as `[x_px, magnitude_px]` points (magnitude ≥ 0 off the baseline). */
57
+ current: Pt[]
58
+ /** Prior-rep ("ghost") point sets, oldest first, same `[x, magnitude]` shape. */
59
+ ghosts?: Pt[][]
60
+ /** The current-line tint — silver→red from {@link ghostLineColor}. */
61
+ tint: string
62
+ /** The axis y (px) the bloom grows from. */
63
+ baseline: number
64
+ /** Grow UP from the baseline (default) or DOWN — the mirrored-dual flip. */
65
+ orientation?: 'up' | 'down'
66
+ /** Per-ghost stroke colour by index; default fades the primary text token. */
67
+ ghostStroke?: (index: number) => string
68
+ /** Current-line stroke width, px. Default 3.5. */
69
+ lineWidth?: number
70
+ }
71
+
72
+ /** The ghost fan + the paper-treated tinted current line. Render inside an `<svg>`. */
73
+ export function GhostBloom({
74
+ current,
75
+ ghosts = [],
76
+ tint,
77
+ baseline,
78
+ orientation = 'up',
79
+ ghostStroke = (i) => alpha(PARCH, 0.1 + i * 0.015),
80
+ lineWidth = 3.5,
81
+ }: GhostBloomProps) {
82
+ const rawId = useId()
83
+ const blurId = `bloom-blur-${rawId.replace(/[^a-zA-Z0-9]/g, '')}`
84
+ const toY = (mag: number) => (orientation === 'down' ? baseline + mag : baseline - mag)
85
+ const toAbs = (pts: Pt[]): Pt[] => pts.map(([px, mag]) => [px, toY(mag)])
86
+ const curD = smoothPath(toAbs(current))
87
+ const ghostDs = ghosts.map((g) => smoothPath(toAbs(g)))
88
+ // Rim-light sits on the side the line grows toward (its "top"); shadow falls the other way.
89
+
90
+ return (
91
+ <>
92
+ <defs>
93
+ <filter id={blurId} x="-50%" y="-50%" width="200%" height="200%">
94
+ <feGaussianBlur stdDeviation={BLUR_STD_DEV} />
95
+ </filter>
96
+ </defs>
97
+
98
+ {/* prior reps — faded ghosts (absolute time → the fan reads as drift). */}
99
+ {ghostDs.map((d, i) => (
100
+ <path
101
+ key={i}
102
+ d={d}
103
+ fill="none"
104
+ stroke={ghostStroke(i)}
105
+ strokeWidth={1.5}
106
+ strokeLinejoin="round"
107
+ />
108
+ ))}
109
+
110
+ {/* ground treatment — a paper-inspired line contact, never a hard black outline. */}
111
+ <path
112
+ d={curD}
113
+ fill="none"
114
+ stroke={alpha(primitiveColors.black, 0.3)}
115
+ strokeWidth={lineWidth + 3}
116
+ strokeLinejoin="round"
117
+ strokeLinecap="round"
118
+ filter={`url(#${blurId})`}
119
+ />
120
+
121
+ {/* the crisp tinted line. */}
122
+ <path
123
+ d={curD}
124
+ fill="none"
125
+ stroke={tint}
126
+ strokeWidth={lineWidth}
127
+ strokeLinejoin="round"
128
+ strokeLinecap="round"
129
+ />
130
+ </>
131
+ )
132
+ }
@@ -0,0 +1,175 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { GhostSpark } from './GhostSpark'
4
+ import { GhostBand } from './GhostBand'
5
+ import { primitiveColors } from '../../../theme/tokens/primitives'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+ import { FATIGUE_STATES, TARGET_TEMPO_SECONDS } from './fatigue-mock'
8
+ import type { PhaseSegment } from './fatigue-model'
9
+
10
+ const PANEL_BG = primitiveColors.charcoal[800]
11
+ const PAGE_BG = primitiveColors.charcoal[900]
12
+ const t = getSemanticColors('dark')
13
+
14
+ const meta: Meta<typeof GhostSpark> = {
15
+ title: 'Workout/Fatigue/Ghost Spark',
16
+ component: GhostSpark,
17
+ tags: ['autodocs'],
18
+ parameters: {
19
+ docs: {
20
+ description: {
21
+ component:
22
+ 'Per-rep velocity-time sparkline on the band model (coherent with the mirrored dual): a wide ' +
23
+ 'phase-coloured band at the bottom (ecc magenta / con cyan, ECC/CON labelled inside, always ' +
24
+ 'shown) with the velocity blooming UP from it — current rep solid over faded ghosts, a control-' +
25
+ 'aware silver/red line tint (silver when controlled, dimming with tempo drift; shades of red on ' +
26
+ 'collapse), over a soft paper-inspired ground (no hard outline). Given `targetTempoSeconds` ' +
27
+ 'the band also PACES: each run fills across `elapsed / target` of its own width and its label ' +
28
+ 'takes the ahead / on-pace / over tone. Every story here passes the prescription the mock reps ' +
29
+ 'were generated against, so the fill you see is the real relationship, not a decoration.',
30
+ },
31
+ },
32
+ },
33
+ }
34
+ export default meta
35
+ type Story = StoryObj<typeof GhostSpark>
36
+
37
+ const box = (child: React.ReactNode) => (
38
+ <View style={{ width: 400, backgroundColor: PANEL_BG, borderRadius: 12, padding: 16 }}>
39
+ {child}
40
+ </View>
41
+ )
42
+ const cur = FATIGUE_STATES[3].model // the full 8-rep set
43
+
44
+ const label = (s: string) => (
45
+ <Text
46
+ style={{ fontSize: 9, letterSpacing: 1, fontFamily: 'monospace', color: t['text-tertiary'] }}
47
+ >
48
+ {s}
49
+ </Text>
50
+ )
51
+
52
+ /** The default spark — band + bloom, ECC/CON labels always visible. */
53
+ export const Default: Story = {
54
+ render: () => (
55
+ <View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 8, alignItems: 'flex-start' }}>
56
+ {label('GHOST SPARK · paced against the prescribed tempo')}
57
+ {box(
58
+ <GhostSpark
59
+ curves={cur.velocityCurves}
60
+ width={360}
61
+ height={190}
62
+ targetTempoSeconds={TARGET_TEMPO_SECONDS}
63
+ />
64
+ )}
65
+ </View>
66
+ ),
67
+ }
68
+
69
+ /** The line tint across the set — early controlled reps stay silver, late reps go through shades of red. */
70
+ export const ControlAwareTint: Story = {
71
+ render: () => (
72
+ <View
73
+ style={{
74
+ backgroundColor: PAGE_BG,
75
+ padding: 28,
76
+ flexDirection: 'row',
77
+ gap: 24,
78
+ flexWrap: 'wrap',
79
+ }}
80
+ >
81
+ {FATIGUE_STATES.map((s) => (
82
+ <View key={s.name} style={{ gap: 8 }}>
83
+ {label(s.name)}
84
+ {box(
85
+ <GhostSpark
86
+ curves={s.model.velocityCurves}
87
+ width={360}
88
+ height={170}
89
+ targetTempoSeconds={s.model.targetTempoSeconds}
90
+ />
91
+ )}
92
+ </View>
93
+ ))}
94
+ </View>
95
+ ),
96
+ }
97
+
98
+ /**
99
+ * A rep with REAL holds (the sample-derived mock collapses its pauses to zero width, so
100
+ * the hold tone never shows there). Segments are handed in with SEAMS between the runs —
101
+ * the shape the model actually produces — to prove the band closes them: one contiguous
102
+ * strip, boundaries on the phase transitions, the hold reading as band material.
103
+ *
104
+ * The two prescribed pauses are `hold` (deliberate, under load); the trailing run is
105
+ * `idle` — undirected dead time after the rep. Both sit in the grey family, separated by
106
+ * VALUE and by the fact that a hold paces and idle never does.
107
+ */
108
+ const PAUSED_SEGMENTS: PhaseSegment[] = [
109
+ { phase: 'eccentric', startMs: 0, endMs: 2100 },
110
+ { phase: 'hold', startMs: 2190, endMs: 2900 },
111
+ { phase: 'concentric', startMs: 2990, endMs: 4050 },
112
+ { phase: 'hold', startMs: 4140, endMs: 4600 },
113
+ { phase: 'idle', startMs: 4690, endMs: 5200 },
114
+ ]
115
+
116
+ /** Prescribed tempo for the paced variants — [ecc, pauseBottom, con, pauseTop] seconds. */
117
+ const PAUSED_TARGET: [number, number, number, number] = [2.1, 0.7, 1.05, 0.45]
118
+
119
+ export const PausedRepBand: Story = {
120
+ render: () => {
121
+ const w = 360
122
+ const h = 60
123
+ const bandTop = 22
124
+ const x = (ms: number) => 12 + (ms / 5400) * (w - 20)
125
+ const Band = (props: { targetTempoSeconds?: [number, number, number, number] }) => (
126
+ <View style={{ width: w, backgroundColor: PANEL_BG, borderRadius: 12, padding: 16 }}>
127
+ <svg width={w} height={h}>
128
+ <GhostBand segments={PAUSED_SEGMENTS} x={x} top={bandTop} showLabels {...props} />
129
+ </svg>
130
+ </View>
131
+ )
132
+ return (
133
+ <View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 16, alignItems: 'flex-start' }}>
134
+ {label('FLAT · no tempo prescribed — nothing to pace against')}
135
+ <Band />
136
+ {label('PACED · every phase ON TARGET — fills to the brim, labels green')}
137
+ <Band targetTempoSeconds={PAUSED_TARGET} />
138
+ {label('PACED · target DOUBLED — every phase now reads early, half-filled, amber')}
139
+ <Band targetTempoSeconds={[4.2, 1.4, 2.1, 0.9]} />
140
+ {label('PACED · target HALVED — every phase overruns, capped full, labels red')}
141
+ <Band targetTempoSeconds={[1.05, 0.35, 0.52, 0.22]} />
142
+ </View>
143
+ )
144
+ },
145
+ }
146
+
147
+ /**
148
+ * BEFORE the first rep. With a prescription there is still something true to draw: the shape
149
+ * of the rep being asked for — its phases at their target durations, unfilled — so the axis
150
+ * previews the work rather than reading as "no data".
151
+ *
152
+ * The right-hand case is the honest fallback: no tempo prescribed, nothing to preview.
153
+ */
154
+ export const EmptyBeforeFirstRep: Story = {
155
+ render: () => (
156
+ <View
157
+ style={{
158
+ backgroundColor: PAGE_BG,
159
+ padding: 28,
160
+ flexDirection: 'row',
161
+ gap: 24,
162
+ alignItems: 'flex-start',
163
+ }}
164
+ >
165
+ <View style={{ gap: 8 }}>
166
+ {label('EMPTY · tempo prescribed — the rep to come')}
167
+ {box(<GhostSpark curves={[]} width={360} height={190} targetTempoSeconds={[3, 1, 2, 1]} />)}
168
+ </View>
169
+ <View style={{ gap: 8 }}>
170
+ {label('EMPTY · no prescription — nothing to preview')}
171
+ {box(<GhostSpark curves={[]} width={360} height={190} />)}
172
+ </View>
173
+ </View>
174
+ ),
175
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { GhostSpark } from './GhostSpark'
4
+ import { FATIGUE_STATES } from './fatigue-mock'
5
+
6
+ const model = FATIGUE_STATES[3].model // the full 8-rep set
7
+
8
+ describe('GhostSpark', () => {
9
+ it('renders without crashing for a populated set', () => {
10
+ render(<GhostSpark curves={model.velocityCurves} width={360} height={180} />)
11
+ expect(screen.getByTestId('ghost-spark')).toBeInTheDocument()
12
+ })
13
+
14
+ it('renders an empty box for no curves', () => {
15
+ render(<GhostSpark curves={[]} width={360} height={180} />)
16
+ expect(screen.getByTestId('ghost-spark')).toBeInTheDocument()
17
+ })
18
+
19
+ it('does not render a peak annotation (chrome removed)', () => {
20
+ render(<GhostSpark curves={model.velocityCurves} width={360} height={180} />)
21
+ expect(screen.queryByText(/peak/)).not.toBeInTheDocument()
22
+ })
23
+
24
+ it('renders one ghost path per prior rep plus the current line + soft ground', () => {
25
+ const { container } = render(
26
+ <GhostSpark curves={model.velocityCurves} width={360} height={180} />
27
+ )
28
+ // 7 ghosts + 2 current (soft ground + tint line) = 9 paths for an 8-rep set.
29
+ expect(container.querySelectorAll('path')).toHaveLength(9)
30
+ })
31
+
32
+ it('draws the wide phase band (rects) for the current rep phase runs', () => {
33
+ const { container } = render(
34
+ <GhostSpark curves={model.velocityCurves} width={360} height={180} />
35
+ )
36
+ // one band rect per phase segment (ecc / pause / con / hold ⇒ ≥ 2).
37
+ expect(container.querySelectorAll('rect').length).toBeGreaterThanOrEqual(2)
38
+ })
39
+
40
+ it('always shows the ECC / CON band labels (no longer hover-gated)', () => {
41
+ render(<GhostSpark curves={model.velocityCurves} width={360} height={180} />)
42
+ expect(screen.getByText('ECC')).toBeInTheDocument()
43
+ expect(screen.getByText('CON')).toBeInTheDocument()
44
+ })
45
+ })
@@ -0,0 +1,126 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * GhostSpark — the per-rep velocity-time sparkline, on the band model (coherent with
4
+ * the mirrored dual: single = one bloom + band; dual = two blooms sharing one band).
5
+ *
6
+ * A WIDE phase-coloured AXIS BAND ({@link GhostBand}) sits at the BOTTOM, filled per the
7
+ * current rep's phase runs (eccentric magenta / concentric cyan / hold and idle grey),
8
+ * each sized to its ACTUAL time extent, with the ECC / HOLD / CON labels shown INSIDE it.
9
+ * Given `targetTempoSeconds` the runs also PACE — muted base, fill earned against the
10
+ * prescribed phase duration, label toned ahead/on-pace/over. Velocity is
11
+ * drawn as MAGNITUDE blooming UP from just above the band ({@link GhostBloom}) — the current
12
+ * rep SOLID over faded grey GHOSTS of the prior reps. Phase is carried by the band colour
13
+ * beneath the line (no ecc-below / con-above split), so single and dual read the same.
14
+ *
15
+ * The current line's TINT is control-aware silver/red (see {@link ghostLineColor}): a
16
+ * controlled rep stays silver (dimming slightly with tempo drift), a collapsing rep goes
17
+ * through shades of red. The line rides a paper-inspired ground treatment (soft contact
18
+ * shadow), not a hard outline.
19
+ */
20
+ import { View } from 'react-native'
21
+ import { ghostLineColor, clamp01 } from './fatigue-tokens'
22
+ import { GhostBand, BAND_H, BAND_GAP } from './GhostBand'
23
+ import { GhostBloom, type Pt } from './GhostBloom'
24
+ import { prescribedSegments, type TempoTuple } from './tempo-pacing'
25
+ import type { RepVelocityCurve } from './fatigue-model'
26
+
27
+ export interface GhostSparkProps {
28
+ /** Per-rep velocity-time curves, oldest first (last = current rep). */
29
+ curves: RepVelocityCurve[]
30
+ width: number
31
+ /** Plot height in px. Default 172. */
32
+ height?: number
33
+ /**
34
+ * Prescribed tempo `[ecc, pauseBottom, con, pauseTop]` seconds — turns on the band's
35
+ * phase PACING (muted base + earned fill, pacing-toned labels). Omit when nothing was
36
+ * prescribed: the band then paints flat, which is the honest read.
37
+ */
38
+ targetTempoSeconds?: TempoTuple | null
39
+ }
40
+
41
+ export function GhostSpark({
42
+ curves,
43
+ width,
44
+ height = 172,
45
+ targetTempoSeconds = null,
46
+ }: GhostSparkProps) {
47
+ const w = width
48
+ const h = height
49
+ const padL = 12
50
+ const padR = 8
51
+ const padTop = 10
52
+ const padBot = 6
53
+
54
+ // Nothing performed yet. With a prescription there is still something true to draw: the
55
+ // SHAPE of the rep being asked for, laid out at its target durations and unfilled. An
56
+ // empty axis says "no data"; this says "here is the rep you are about to do".
57
+ if (curves.length === 0) {
58
+ const prescribed = prescribedSegments(targetTempoSeconds)
59
+ if (prescribed.length === 0) {
60
+ return <View testID="ghost-spark" style={{ width: w, height: h }} />
61
+ }
62
+ const totalMs = prescribed[prescribed.length - 1].endMs
63
+ const bandTopEmpty = h - padBot - BAND_H
64
+ const xEmpty = (ms: number): number => padL + (ms / (totalMs * 1.04)) * (w - padL - padR)
65
+ return (
66
+ <View testID="ghost-spark" style={{ paddingHorizontal: 4 }}>
67
+ <svg width={w} height={h}>
68
+ <GhostBand
69
+ segments={prescribed}
70
+ x={xEmpty}
71
+ top={bandTopEmpty}
72
+ showLabels
73
+ targetTempoSeconds={targetTempoSeconds}
74
+ prescribed
75
+ />
76
+ </svg>
77
+ </View>
78
+ )
79
+ }
80
+
81
+ const cur = curves[curves.length - 1]
82
+ const allSamples = curves.flatMap((c) => c.samples)
83
+ const vmax = Math.max(0.01, ...allSamples.map((s) => s.velocityMps)) * 1.06
84
+ const axisMaxT =
85
+ Math.max(1, ...curves.map((c) => c.samples[c.samples.length - 1]?.tMs ?? 0)) * 1.04
86
+
87
+ // Band pinned to the bottom; the bloom baseline sits a small gap above its top edge,
88
+ // and magnitude blooms UP toward padTop.
89
+ const bandBottom = h - padBot
90
+ const bandTop = bandBottom - BAND_H
91
+ const baseline = bandTop - BAND_GAP
92
+ const plotH = Math.max(1, baseline - padTop)
93
+ const x = (ms: number) => padL + (ms / axisMaxT) * (w - padL - padR)
94
+ const mag = (v: number) => clamp01(v / vmax) * plotH
95
+
96
+ const lineTint = ghostLineColor(cur.tempoDeviation, cur.grindSignature)
97
+ const curPts: Pt[] = cur.samples.map((s) => [x(s.tMs), mag(s.velocityMps)])
98
+ const ghostPts: Pt[][] = curves
99
+ .slice(0, -1)
100
+ .map((c) => c.samples.map((s): Pt => [x(s.tMs), mag(s.velocityMps)]))
101
+
102
+ return (
103
+ <View testID="ghost-spark" style={{ paddingHorizontal: 4 }}>
104
+ <svg width={w} height={h}>
105
+ {/* the ghost fan + paper-treated tinted current line, blooming up from the band. */}
106
+ <GhostBloom
107
+ current={curPts}
108
+ ghosts={ghostPts}
109
+ tint={lineTint}
110
+ baseline={baseline}
111
+ orientation="up"
112
+ />
113
+
114
+ {/* the WIDE phase-colored axis band at the bottom — the sole carrier of phase,
115
+ filled per the current rep's phase runs, ECC/HOLD/CON labelled INSIDE. */}
116
+ <GhostBand
117
+ segments={cur.phaseSegments}
118
+ x={x}
119
+ top={bandTop}
120
+ showLabels
121
+ targetTempoSeconds={targetTempoSeconds}
122
+ />
123
+ </svg>
124
+ </View>
125
+ )
126
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { LiveFatigueCard } from './LiveFatigueCard'
4
+ import { Surface } from '../../ui/surface/Surface'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+ import { FATIGUE_STATES, WARMING_UP_MODEL, buildMockModel } from './fatigue-mock'
7
+
8
+ const t = getSemanticColors('dark')
9
+
10
+ const meta: Meta<typeof LiveFatigueCard> = {
11
+ title: 'Workout/Fatigue/Live Fatigue Card',
12
+ component: LiveFatigueCard,
13
+ tags: ['autodocs'],
14
+ parameters: {
15
+ docs: {
16
+ description: {
17
+ component:
18
+ 'The vertical live fatigue card — consumes one `LiveFatigueModel`. Composes `VerdictHero` ' +
19
+ '(RPE + verdict word) · `FatigueLights` (VEL/ROM/TEMPO dots) · `RomProgressionChart` · ' +
20
+ '`GhostSpark` (tempo embedded). Grounded on the `Surface` **base** plane — one step ' +
21
+ 'above the `background` shell the live stage paints — separated by the alpha ' +
22
+ '`hairline-default` edge and finished with the shared **paper** accent (matte grain + ' +
23
+ 'top rim-light + contact shadow), the hero-surface treatment from the surface north-star.',
24
+ },
25
+ },
26
+ },
27
+ argTypes: {
28
+ width: { control: { type: 'number', min: 260, max: 420, step: 2 } },
29
+ },
30
+ }
31
+ export default meta
32
+ type Story = StoryObj<typeof LiveFatigueCard>
33
+
34
+ /** The live stage the card actually sits on — the `background` shell plane, not a raw hex. */
35
+ const Frame = ({ children }: { children: React.ReactNode }) => (
36
+ <Surface level="background" style={{ padding: 28, alignItems: 'flex-start' }}>
37
+ {children}
38
+ </Surface>
39
+ )
40
+
41
+ /** The default (form breaking down) card. */
42
+ export const Default: Story = {
43
+ args: { model: FATIGUE_STATES[3].model, width: 318, height: 508 },
44
+ render: (args) => (
45
+ <Frame>
46
+ <LiveFatigueCard {...args} />
47
+ </Frame>
48
+ ),
49
+ }
50
+
51
+ /** The four verdict states side by side — the whole spectrum. */
52
+ export const States: Story = {
53
+ render: () => (
54
+ <Surface
55
+ level="background"
56
+ style={{
57
+ padding: 28,
58
+ flexDirection: 'row',
59
+ gap: 20,
60
+ alignItems: 'flex-start',
61
+ }}
62
+ >
63
+ {FATIGUE_STATES.map((s) => (
64
+ <View key={s.name} style={{ gap: 8 }}>
65
+ <Text
66
+ style={{
67
+ fontSize: 9,
68
+ letterSpacing: 1,
69
+ fontFamily: 'monospace',
70
+ color: t['text-tertiary'],
71
+ }}
72
+ >
73
+ {s.name}
74
+ </Text>
75
+ <LiveFatigueCard model={s.model} width={300} height={500} />
76
+ </View>
77
+ ))}
78
+ </Surface>
79
+ ),
80
+ }
81
+
82
+ /**
83
+ * Mid-set WITH a plan attached — the ROM chart draws the remaining reps as solid to-do sections,
84
+ * the same upcoming-rep treatment the velocity strip uses. `plannedReps` rides the model straight
85
+ * through to `RomProgressionChart`, and comes from the mock's ONE rep count, so it agrees with
86
+ * the velocity side when the two sit together in the panel.
87
+ */
88
+ export const WithPlannedReps: Story = {
89
+ render: () => (
90
+ <Frame>
91
+ <LiveFatigueCard model={FATIGUE_STATES[1].model} width={318} height={508} />
92
+ </Frame>
93
+ ),
94
+ }
95
+
96
+ /**
97
+ * The SAME mid-set model with NO plan attached — no target exists, so the chart shows the
98
+ * performed reps only. The gap reads as a gap; nothing is defaulted in.
99
+ *
100
+ * `plannedReps` is stripped EXPLICITLY rather than just left off: the mock now supplies it by
101
+ * default, so "no plan attached" has to be stated to stay true.
102
+ */
103
+ export const WithoutPlannedReps: Story = {
104
+ render: () => (
105
+ <Frame>
106
+ <LiveFatigueCard
107
+ model={buildMockModel(FATIGUE_STATES[1].current, {
108
+ rpe: FATIGUE_STATES[1].model.rpe,
109
+ verdict: FATIGUE_STATES[1].model.verdict,
110
+ plannedReps: undefined,
111
+ })}
112
+ width={318}
113
+ height={508}
114
+ />
115
+ </Frame>
116
+ ),
117
+ }
118
+
119
+ /** Warming up — a cold-start set (< 2 reps): neutral verdict, em-dash RPE, no reference lines. */
120
+ export const WarmingUp: Story = {
121
+ render: () => (
122
+ <Frame>
123
+ <LiveFatigueCard model={WARMING_UP_MODEL} width={318} height={508} />
124
+ </Frame>
125
+ ),
126
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { LiveFatigueCard } from './LiveFatigueCard'
4
+ import { FATIGUE_STATES, WARMING_UP_MODEL } from './fatigue-mock'
5
+
6
+ const model = FATIGUE_STATES[3].model
7
+
8
+ describe('LiveFatigueCard', () => {
9
+ it('composes the verdict hero, lights, ROM chart and ghost spark', () => {
10
+ render(<LiveFatigueCard model={model} width={318} height={508} />)
11
+ expect(screen.getByTestId('live-fatigue-card')).toBeInTheDocument()
12
+ expect(screen.getByTestId('verdict-hero')).toBeInTheDocument()
13
+ expect(screen.getByTestId('fatigue-lights')).toBeInTheDocument()
14
+ expect(screen.getByTestId('rom-progression')).toBeInTheDocument()
15
+ expect(screen.getByTestId('ghost-spark')).toBeInTheDocument()
16
+ })
17
+
18
+ it('shows the verdict word from the model', () => {
19
+ render(<LiveFatigueCard model={model} />)
20
+ expect(screen.getByText('Form breaking down')).toBeInTheDocument()
21
+ })
22
+
23
+ it('renders a warming-up (cold start) model without a verdict', () => {
24
+ render(<LiveFatigueCard model={WARMING_UP_MODEL} width={318} height={508} />)
25
+ expect(screen.getByText('Warming up')).toBeInTheDocument()
26
+ expect(screen.getByText('—')).toBeInTheDocument()
27
+ })
28
+ })