@titan-design/react-ui 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bodymap.js +173 -343
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +173 -343
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +416 -18
- package/dist/index.d.ts +416 -18
- package/dist/index.js +2512 -1307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2480 -1299
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1644 -1575
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1645 -1576
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +409 -415
- package/dist/theme/index.d.ts +409 -415
- package/dist/theme/index.js +234 -408
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +227 -399
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +141 -140
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +141 -140
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/arch/Architecture.stories.tsx +9 -9
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +2 -2
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
- package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
- package/src/components/custom/Workout/DeviationBar.tsx +3 -2
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -2
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
- package/src/components/custom/Workout/MesoCard.tsx +3 -1
- package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
- package/src/components/custom/Workout/RestTimer.tsx +2 -2
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionHeader.tsx +1 -1
- package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +63 -18
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/StatusDot.tsx +5 -4
- package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
- package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
- package/src/components/custom/Workout/WeightBadge.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
- package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +3 -3
- package/src/components/shell/DeviceMenu.tsx +2 -2
- package/src/components/shell/SideNav.tsx +1 -1
- package/src/components/shell/TopBar.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/avatar/Avatar.tsx +2 -2
- package/src/components/ui/badge/Badge.tsx +2 -2
- package/src/components/ui/card/Card.tsx +3 -3
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +2 -2
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +3 -3
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.tsx +4 -4
- package/src/components/ui/select/Select.tsx +3 -3
- package/src/components/ui/surface/Surface.stories.tsx +3 -3
- package/src/components/ui/surface/Surface.test.tsx +32 -19
- package/src/components/ui/surface/Surface.tsx +3 -3
- package/src/components/ui/surface/SurfaceContext.ts +19 -29
- package/src/components/ui/surface/surface.contract.test.ts +57 -44
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
- package/src/stories/PresetShowcase.stories.tsx +21 -21
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +874 -0
- package/src/theme/ColorPrimitives.stories.tsx +348 -0
- package/src/theme/Depth.stories.tsx +312 -0
- package/src/theme/DepthCalibration.stories.tsx +655 -0
- package/src/theme/ThemeProvider.tsx +1 -1
- package/src/theme/barrel.ts +7 -14
- package/src/theme/color-stories.coverage.test.ts +125 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/color-utils.ts +197 -0
- package/src/theme/config.ts +0 -8
- package/src/theme/depth-calibration.test.tsx +97 -0
- package/src/theme/elevation.test.ts +62 -1
- package/src/theme/elevation.ts +34 -8
- package/src/theme/global.css +47 -53
- package/src/theme/grey-ramp.test.ts +166 -0
- package/src/theme/materials.test.ts +106 -0
- package/src/theme/materials.ts +211 -0
- package/src/theme/presets/audiobook.ts +4 -4
- package/src/theme/presets/types.ts +3 -3
- package/src/theme/semantic-contrast.test.ts +83 -0
- package/src/theme/tokens/primitives.ts +62 -115
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -508
- package/src/theme/Colors.stories.tsx +0 -323
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
|
@@ -0,0 +1,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 { greyRamp } 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 = greyRamp[950]
|
|
11
|
+
const PAGE_BG = greyRamp[975]
|
|
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
|
+
})
|