@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,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { FatigueLights } from './FatigueLights'
4
+ import type { FatigueVerdict } from './fatigue-model'
5
+
6
+ const dims: FatigueVerdict['dimensions'] = { velocityLoss: 'alarm', rom: 'warn', tempo: 'ok' }
7
+
8
+ describe('FatigueLights', () => {
9
+ it('renders the three VEL/ROM/TEMPO labels', () => {
10
+ render(<FatigueLights dimensions={dims} />)
11
+ expect(screen.getByText('VEL')).toBeInTheDocument()
12
+ expect(screen.getByText('ROM')).toBeInTheDocument()
13
+ expect(screen.getByText('TEMPO')).toBeInTheDocument()
14
+ })
15
+
16
+ it('composes three StatusDot primitives', () => {
17
+ render(<FatigueLights dimensions={dims} />)
18
+ expect(screen.getAllByTestId('status-dot')).toHaveLength(3)
19
+ })
20
+
21
+ it('labels each dimension with its detail + status word', () => {
22
+ render(<FatigueLights dimensions={dims} />)
23
+ expect(screen.getByLabelText('Velocity loss, alarm')).toBeInTheDocument()
24
+ expect(screen.getByLabelText('ROM depth, watch')).toBeInTheDocument()
25
+ expect(screen.getByLabelText('Tempo, ok')).toBeInTheDocument()
26
+ })
27
+
28
+ it('renders neutral "warming up" lights when dimensions are null', () => {
29
+ render(<FatigueLights dimensions={null} />)
30
+ expect(screen.getByLabelText('Velocity loss, warming up')).toBeInTheDocument()
31
+ expect(screen.getAllByTestId('status-dot')).toHaveLength(3)
32
+ })
33
+ })
@@ -0,0 +1,83 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * FatigueLights — the three "why" status dots (VEL · ROM · TEMPO) that stand behind
4
+ * the verdict word: the supporting reason the verdict reads the way it does. Each dot
5
+ * COMPOSES the shared {@link StatusDot} primitive (glow) wrapped in a {@link Tooltip}
6
+ * that reveals the dimension detail on hover.
7
+ *
8
+ * Warming up (`dimensions === null`) shows three neutral dots.
9
+ */
10
+ import { View, Text } from 'react-native'
11
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
12
+ import { StatusDot, type StatusDotVariant } from '../Workout/StatusDot'
13
+ import { Tooltip } from '../../ui/tooltip/Tooltip'
14
+ import { FONT_MONO } from './fatigue-tokens'
15
+ import type { DimensionTone, FatigueVerdict } from './fatigue-model'
16
+
17
+ const t = getSemanticColors('dark')
18
+
19
+ /** Tone → the StatusDot variant (ok=success, warn=warning, alarm=error). */
20
+ const TONE_VARIANT: Record<DimensionTone, StatusDotVariant> = {
21
+ ok: 'success',
22
+ warn: 'warning',
23
+ alarm: 'error',
24
+ }
25
+ const TONE_WORD: Record<DimensionTone, string> = { ok: 'ok', warn: 'watch', alarm: 'alarm' }
26
+
27
+ export interface FatigueLightsProps {
28
+ /** The three per-dimension tones. `null` = warming up (all neutral). */
29
+ dimensions: FatigueVerdict['dimensions'] | null
30
+ /** Distribute the three lights evenly across the full width (space-between). Default false (left-grouped). */
31
+ spread?: boolean
32
+ }
33
+
34
+ function Light({
35
+ label,
36
+ tone,
37
+ detail,
38
+ }: {
39
+ label: string
40
+ tone: DimensionTone | null
41
+ detail: string
42
+ }) {
43
+ const variant: StatusDotVariant = tone ? TONE_VARIANT[tone] : 'neutral'
44
+ const word = tone ? TONE_WORD[tone] : 'warming up'
45
+ return (
46
+ <Tooltip label={`${detail} · ${word}`} placement="bottom">
47
+ <View
48
+ accessibilityLabel={`${detail}, ${word}`}
49
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}
50
+ >
51
+ <StatusDot variant={variant} size="sm" glow />
52
+ <Text
53
+ style={{
54
+ fontSize: 9,
55
+ letterSpacing: 0.6,
56
+ fontFamily: FONT_MONO,
57
+ color: t['text-secondary'],
58
+ }}
59
+ >
60
+ {label}
61
+ </Text>
62
+ </View>
63
+ </Tooltip>
64
+ )
65
+ }
66
+
67
+ export function FatigueLights({ dimensions, spread = false }: FatigueLightsProps) {
68
+ return (
69
+ <View
70
+ testID="fatigue-lights"
71
+ style={{
72
+ flexDirection: 'row',
73
+ gap: spread ? 0 : 16,
74
+ alignItems: 'center',
75
+ justifyContent: spread ? 'space-between' : 'flex-start',
76
+ }}
77
+ >
78
+ <Light label="VEL" tone={dimensions?.velocityLoss ?? null} detail="Velocity loss" />
79
+ <Light label="ROM" tone={dimensions?.rom ?? null} detail="ROM depth" />
80
+ <Light label="TEMPO" tone={dimensions?.tempo ?? null} detail="Tempo" />
81
+ </View>
82
+ )
83
+ }
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import { GhostBand, BAND_H, type GhostBandProps } from './GhostBand'
4
+ import { PHASE_AXIS_COLOR, PHASE_AXIS_BASE_COLOR, PACING_TONE } from './fatigue-tokens'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
+ import type { PhaseSegment } from './fatigue-model'
7
+
8
+ const t = getSemanticColors('dark')
9
+
10
+ /** Phase runs with SEAMS between them — the shape the sample-derived model actually produces. */
11
+ const segments: PhaseSegment[] = [
12
+ { phase: 'eccentric', startMs: 0, endMs: 1182 },
13
+ { phase: 'idle', startMs: 1273, endMs: 1600 },
14
+ { phase: 'concentric', startMs: 1691, endMs: 3545 },
15
+ ]
16
+
17
+ const x = (ms: number) => 12 + (ms / 4000) * 300
18
+
19
+ const band = (segs: PhaseSegment[] = segments, props: Partial<GhostBandProps> = {}) =>
20
+ render(
21
+ <svg>
22
+ <GhostBand segments={segs} x={x} top={0} height={BAND_H} {...props} />
23
+ </svg>
24
+ ).container
25
+
26
+ const geom = (r: Element) => ({
27
+ x: Number(r.getAttribute('x')),
28
+ width: Number(r.getAttribute('width')),
29
+ fill: r.getAttribute('fill'),
30
+ })
31
+
32
+ /** The strip floor — the one rect sitting directly in the clipped group. */
33
+ const floorOf = (c: HTMLElement) => geom(c.querySelector('g[clip-path] > rect')!)
34
+ /** Per-run BASE rects — these carry the run's true geometry (full width). */
35
+ const basesOf = (c: HTMLElement) =>
36
+ Array.from(c.querySelectorAll('[data-testid="ghost-band-base"]')).map(geom)
37
+ /** Per-run FILL rects — width is the share of the run earned against target. */
38
+ const fillsOf = (c: HTMLElement) =>
39
+ Array.from(c.querySelectorAll('[data-testid="ghost-band-fill"]')).map(geom)
40
+ const labelsOf = (c: HTMLElement) =>
41
+ Array.from(c.querySelectorAll('text')).map((n) => ({
42
+ text: n.textContent,
43
+ fill: n.getAttribute('fill'),
44
+ }))
45
+
46
+ /** A rep whose phases each run EXACTLY their prescribed duration. */
47
+ const onPace: PhaseSegment[] = [
48
+ { phase: 'eccentric', startMs: 0, endMs: 2600 },
49
+ { phase: 'hold', startMs: 2600, endMs: 3000 },
50
+ { phase: 'concentric', startMs: 3000, endMs: 3950 },
51
+ ]
52
+ const TEMPO: [number, number, number, number] = [2.6, 0.4, 0.95, 0.28]
53
+
54
+ describe('GhostBand', () => {
55
+ it('draws a contiguous strip — each run butts against the next with no gap', () => {
56
+ const runs = basesOf(band())
57
+ runs.forEach((r, i) => {
58
+ const next = runs[i + 1]
59
+ if (next) expect(r.x + r.width).toBeCloseTo(next.x, 5)
60
+ })
61
+ })
62
+
63
+ it('floors the whole rep with the idle tone so a pause reads as band, not a hole', () => {
64
+ const floor = floorOf(band())
65
+ expect(floor.fill).toBe(PHASE_AXIS_COLOR.idle)
66
+ expect(floor.x).toBeCloseTo(x(0), 5)
67
+ expect(floor.x + floor.width).toBeCloseTo(x(3545), 5)
68
+ })
69
+
70
+ it('paints the pause run in the idle tone', () => {
71
+ expect(fillsOf(band()).map((r) => r.fill)).toEqual([
72
+ PHASE_AXIS_COLOR.eccentric,
73
+ PHASE_AXIS_COLOR.idle,
74
+ PHASE_AXIS_COLOR.concentric,
75
+ ])
76
+ })
77
+
78
+ it('renders nothing when every run is zero-width', () => {
79
+ const c = band([{ phase: 'idle', startMs: 500, endMs: 500 }])
80
+ expect(c.querySelectorAll('rect')).toHaveLength(0)
81
+ })
82
+
83
+ it('separates a hold from idle by VALUE, so a narrow hold still reads as a hold', () => {
84
+ const runs = fillsOf(
85
+ band([
86
+ { phase: 'eccentric', startMs: 0, endMs: 1000 },
87
+ { phase: 'hold', startMs: 1000, endMs: 1500 },
88
+ { phase: 'concentric', startMs: 1500, endMs: 3000 },
89
+ ])
90
+ )
91
+ expect(runs[1].fill).toBe(PHASE_AXIS_COLOR.hold)
92
+ expect(runs[1].fill).not.toBe(PHASE_AXIS_COLOR.idle)
93
+ // A filled hold sits BRIGHTER than idle, an unfilled one darker — never the same.
94
+ expect(PHASE_AXIS_COLOR.hold).not.toBe(PHASE_AXIS_BASE_COLOR.hold)
95
+ expect(PHASE_AXIS_BASE_COLOR.hold).not.toBe(PHASE_AXIS_COLOR.idle)
96
+ })
97
+
98
+ it('labels ONLY the movement phases — hold and idle stay unnamed', () => {
99
+ // The pacing-tone contrast floor is measured against ECC/CON backgrounds only, so this
100
+ // exclusion is what keeps that narrower set honest. Widening it here needs the tones
101
+ // re-measured against whatever new background can sit behind a label.
102
+ const c = band(
103
+ [
104
+ { phase: 'eccentric', startMs: 0, endMs: 1000 },
105
+ { phase: 'hold', startMs: 1000, endMs: 1500 },
106
+ { phase: 'concentric', startMs: 1500, endMs: 3000 },
107
+ { phase: 'idle', startMs: 3000, endMs: 4000 },
108
+ ],
109
+ { showLabels: true }
110
+ )
111
+ expect(labelsOf(c).map((l) => l.text)).toEqual(['ECC', 'CON'])
112
+ })
113
+
114
+ it('DROPS a label that will not fit rather than clipping it', () => {
115
+ // A 300 ms concentric is ~22 px here — over the old flat 20 px floor, which rendered a
116
+ // word that ran past its own run and clipped.
117
+ const c = band(
118
+ [
119
+ { phase: 'eccentric', startMs: 0, endMs: 1000 },
120
+ { phase: 'concentric', startMs: 1000, endMs: 1300 },
121
+ ],
122
+ { showLabels: true }
123
+ )
124
+ expect(labelsOf(c).map((l) => l.text)).toEqual(['ECC'])
125
+ })
126
+
127
+ it('rounds the band once, as a clip — never per run', () => {
128
+ const c = band()
129
+ expect(c.querySelector('clipPath rect')?.getAttribute('rx')).toBe('2')
130
+ const runRects = Array.from(c.querySelectorAll('g[clip-path] > g > rect'))
131
+ expect(runRects.every((r) => r.getAttribute('rx') == null)).toBe(true)
132
+ })
133
+
134
+ // --- pacing -----------------------------------------------------------------
135
+
136
+ it('fills every run completely when no tempo is prescribed', () => {
137
+ const bases = basesOf(band())
138
+ fillsOf(band()).forEach((f, i) => expect(f.width).toBeCloseTo(bases[i].width, 5))
139
+ })
140
+
141
+ it('leaves the geometry alone when pacing turns on — only the FILL changes', () => {
142
+ const flat = basesOf(band(onPace))
143
+ const paced = basesOf(band(onPace, { targetTempoSeconds: TEMPO }))
144
+ expect(paced).toEqual(flat)
145
+ })
146
+
147
+ it('fills a run that hit its target to the brim', () => {
148
+ const c = band(onPace, { targetTempoSeconds: TEMPO })
149
+ const bases = basesOf(c)
150
+ fillsOf(c).forEach((f, i) => expect(f.width).toBeCloseTo(bases[i].width, 5))
151
+ })
152
+
153
+ it('leaves a FAST phase partly unfilled — the base shows through', () => {
154
+ // A 1.3 s eccentric against a 2.6 s target is exactly half paced.
155
+ const fast: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1300 }]
156
+ const c = band(fast, { targetTempoSeconds: TEMPO })
157
+ expect(fillsOf(c)[0].width).toBeCloseTo(basesOf(c)[0].width * 0.5, 5)
158
+ })
159
+
160
+ it('CAPS a slow phase at its own run width rather than overflowing', () => {
161
+ const slow: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 9000 }]
162
+ const c = band(slow, { targetTempoSeconds: TEMPO })
163
+ expect(fillsOf(c)[0].width).toBeCloseTo(basesOf(c)[0].width, 5)
164
+ })
165
+
166
+ it('paints the muted base UNDER the fill so an unfilled remainder is still band', () => {
167
+ const fast: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1300 }]
168
+ const c = band(fast, { targetTempoSeconds: TEMPO })
169
+ expect(basesOf(c)[0].fill).toBe(PHASE_AXIS_BASE_COLOR.eccentric)
170
+ expect(fillsOf(c)[0].fill).toBe(PHASE_AXIS_COLOR.eccentric)
171
+ })
172
+
173
+ it('tones each label by its OWN pacing, independently', () => {
174
+ const mixed: PhaseSegment[] = [
175
+ { phase: 'eccentric', startMs: 0, endMs: 1000 }, // 1.0s vs 2.6s → ahead
176
+ { phase: 'concentric', startMs: 1000, endMs: 4000 }, // 3.0s vs 0.95s → over
177
+ ]
178
+ const c = band(mixed, { targetTempoSeconds: TEMPO, showLabels: true })
179
+ expect(labelsOf(c)).toEqual([
180
+ { text: 'ECC', fill: PACING_TONE.ahead },
181
+ { text: 'CON', fill: PACING_TONE.over },
182
+ ])
183
+ })
184
+
185
+ it('tones a label ON PACE when the phase hits its target', () => {
186
+ const c = band(onPace, { targetTempoSeconds: TEMPO, showLabels: true })
187
+ expect(labelsOf(c).map((l) => l.fill)).toEqual([PACING_TONE.onPace, PACING_TONE.onPace])
188
+ })
189
+
190
+ it('keeps labels plain when nothing is prescribed to pace against', () => {
191
+ const c = band(onPace, { showLabels: true })
192
+ expect(labelsOf(c).every((l) => l.fill === t['text-primary'])).toBe(true)
193
+ })
194
+
195
+ // --- prescribed (nothing performed yet) --------------------------------------
196
+
197
+ it('paints a PRESCRIBED rep entirely unfilled — nothing has been performed', () => {
198
+ const c = band(onPace, { targetTempoSeconds: TEMPO, prescribed: true })
199
+ fillsOf(c).forEach((f) => expect(f.width).toBe(0))
200
+ })
201
+
202
+ it('still draws the prescribed runs at full width, so the shape reads', () => {
203
+ const plain = basesOf(band(onPace, { targetTempoSeconds: TEMPO }))
204
+ const pre = basesOf(band(onPace, { targetTempoSeconds: TEMPO, prescribed: true }))
205
+ expect(pre).toEqual(plain)
206
+ })
207
+
208
+ it('keeps prescribed labels PLAIN — an unstarted rep is neither ahead nor behind', () => {
209
+ const c = band(onPace, { targetTempoSeconds: TEMPO, prescribed: true, showLabels: true })
210
+ expect(labelsOf(c).every((l) => l.fill === t['text-primary'])).toBe(true)
211
+ })
212
+
213
+ // --- the well ---------------------------------------------------------------
214
+
215
+ it('recesses every run, so an unearned remainder reads as empty channel', () => {
216
+ const c = band(onPace, { targetTempoSeconds: TEMPO })
217
+ expect(c.querySelectorAll('[data-testid="ghost-band-well"]')).toHaveLength(onPace.length)
218
+ })
219
+
220
+ it('sits the well UNDER the fill — a filled run hides its own recess', () => {
221
+ const c = band(onPace, { targetTempoSeconds: TEMPO })
222
+ const order = Array.from(c.querySelectorAll('g[clip-path] > g > rect')).map((r) =>
223
+ r.getAttribute('data-testid')
224
+ )
225
+ expect(order.slice(0, 3)).toEqual(['ghost-band-base', 'ghost-band-well', 'ghost-band-fill'])
226
+ })
227
+
228
+ it('spans the well across the whole run, not just the unfilled part', () => {
229
+ // Recessing only the exposed remainder would step in tone at the fill's edge.
230
+ const fast: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1300 }]
231
+ const c = band(fast, { targetTempoSeconds: TEMPO })
232
+ const well = geom(c.querySelector('[data-testid="ghost-band-well"]')!)
233
+ expect(well.width).toBeCloseTo(basesOf(c)[0].width, 5)
234
+ })
235
+ })
@@ -0,0 +1,246 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * GhostBand — the wide phase-coloured AXIS BAND that carries movement phase for the
4
+ * ghost family (eccentric magenta / concentric cyan / hold and idle grey), with the
5
+ * ECC / HOLD / CON labels rendered INSIDE it. Extracted from GhostSpark so the single
6
+ * sparkline and a future top/bottom dual compose the SAME band instead of re-rolling it.
7
+ *
8
+ * It is a pure SVG group: the caller owns the x-scale (`x`) and the band's vertical
9
+ * placement (`top`), so the same band serves a bottom-pinned single or a centred dual.
10
+ *
11
+ * ## Pacing
12
+ *
13
+ * Given a target tempo, each run paints a MUTED base with a brighter fill growing
14
+ * left-to-right across `elapsed / target` of its own width (capped at full), and its label
15
+ * takes the pacing tone — ahead / on pace / over. The band's GEOMETRY is untouched by this:
16
+ * runs keep their actual-time positions and widths so the internal boundaries still land
17
+ * under the sparkline's phase transitions. Pacing is a fill INSIDE the existing bars, never
18
+ * a re-layout of them.
19
+ *
20
+ * The two encodings read together: a slow phase is a WIDE run filled to the brim with an
21
+ * error-toned label; a fast phase is a NARROW run only partly filled, warning-toned.
22
+ *
23
+ * Every run is recessed into a WELL, so the part not yet earned reads as empty channel
24
+ * rather than as a darker shade of the phase. The well needs no flag: where nothing is
25
+ * prescribed the fill covers the whole run and the recess is hidden behind it.
26
+ */
27
+ import { useId } from 'react'
28
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
29
+ import { primitiveColors } from '../../../theme/tokens/primitives'
30
+ import { FONT_UI, PHASE_AXIS_COLOR, PHASE_AXIS_BASE_COLOR } from './fatigue-tokens'
31
+ import { phaseFillFraction, pacingTone, phaseTargetsMs, type TempoTuple } from './tempo-pacing'
32
+ import type { PhaseSegment } from './fatigue-model'
33
+
34
+ const t = getSemanticColors('dark')
35
+
36
+ /** Band height in px. */
37
+ export const BAND_H = 16
38
+ /** Gap between the band's near edge and a bloom's baseline. */
39
+ export const BAND_GAP = 4
40
+
41
+ /**
42
+ * Phase → the word rendered inside its run.
43
+ *
44
+ * Only the two MOVEMENT phases are named. `idle` never was — dead time has no name — and
45
+ * `hold` is deliberately unlabelled too: holds are the narrowest runs on the band, so the
46
+ * word was dropping out at exactly the sizes it mattered, and the runs it did fit were
47
+ * spending their whole width on it. A hold is legible without the word, from its tone,
48
+ * its position between the movement phases, and the fact that it fills.
49
+ */
50
+ const PHASE_LABEL: Partial<Record<PhaseSegment['phase'], string>> = {
51
+ eccentric: 'ECC',
52
+ concentric: 'CON',
53
+ }
54
+
55
+ /** Label glyph advance at fontSize 8 + letterSpacing 1, plus a little side padding. */
56
+ const LABEL_CH_PX = 7
57
+ const LABEL_PAD_PX = 6
58
+
59
+ /** The well shades toward black so it recesses every phase tone identically. */
60
+ const WELL_SHADE = primitiveColors.black
61
+ /** The faint light line on the well's floor — the far lip catching light. */
62
+ const WELL_FLOOR_LIGHT = primitiveColors.white
63
+
64
+ /**
65
+ * Does `label` fit inside a run `segW` px wide? Sized per WORD, not a flat floor — a flat
66
+ * 20 px clipped 'HOLD' to 'HOL' on exactly the short top-hold runs the label exists for.
67
+ */
68
+ function labelFits(label: string, segW: number): boolean {
69
+ return segW >= label.length * LABEL_CH_PX + LABEL_PAD_PX
70
+ }
71
+
72
+ export interface GhostBandProps {
73
+ /** The current rep's phase runs, in stream order. */
74
+ segments: PhaseSegment[]
75
+ /** Time (ms) → px mapper, owned by the caller. */
76
+ x: (ms: number) => number
77
+ /** The band's top edge, px. */
78
+ top: number
79
+ /** Band height, px. Default {@link BAND_H}. */
80
+ height?: number
81
+ /** Reveal the ECC / HOLD / CON labels inside the band. */
82
+ showLabels?: boolean
83
+ /**
84
+ * Label colour when there is no pacing tone to apply. Default the primary text token.
85
+ * Ignored for a run that paces — that label takes {@link pacingTone}.
86
+ */
87
+ labelColor?: string
88
+ /**
89
+ * Prescribed tempo `[ecc, pauseBottom, con, pauseTop]` in seconds. Supplying it turns on
90
+ * PACING: runs paint muted-base + fill, and labels take the ahead/on-pace/over tone.
91
+ *
92
+ * Omit it and the band paints flat at full tone with plain labels — the pre-pacing look,
93
+ * which is also the honest one when nothing was prescribed to pace against.
94
+ */
95
+ targetTempoSeconds?: TempoTuple | null
96
+ /**
97
+ * These runs are PRESCRIBED, not performed — the set has not started. Every run paints
98
+ * unfilled at its base tone with a plain label, because nothing has been paced yet.
99
+ *
100
+ * Used by the empty state, where the band shows the SHAPE of the rep that was asked for
101
+ * (from the target tempo) rather than an axis with nothing on it.
102
+ */
103
+ prescribed?: boolean
104
+ }
105
+
106
+ /**
107
+ * The phase-coloured axis band — ONE contiguous strip whose internal boundaries land
108
+ * exactly on the sparkline's phase transitions.
109
+ *
110
+ * Contiguity is structural, not incidental: the band is a single rounded silhouette
111
+ * (clipped), floored with the idle tone across its whole time extent, and each phase run
112
+ * paints SQUARE inside that clip, extended to the next run's start. No per-segment inset
113
+ * and no per-segment rounding — those read as gaps between sections.
114
+ */
115
+ export function GhostBand({
116
+ segments,
117
+ x,
118
+ top,
119
+ height = BAND_H,
120
+ showLabels = false,
121
+ labelColor = t['text-primary'],
122
+ targetTempoSeconds = null,
123
+ prescribed = false,
124
+ }: GhostBandProps) {
125
+ const rawId = useId()
126
+ const safeId = rawId.replace(/[^a-zA-Z0-9]/g, '')
127
+ const clipId = `ghost-band-${safeId}`
128
+ const wellId = `ghost-band-well-${safeId}`
129
+
130
+ const drawn = segments.filter((seg) => x(seg.endMs) - x(seg.startMs) > 0)
131
+ if (drawn.length === 0) return null
132
+
133
+ const bandLeft = x(drawn[0].startMs)
134
+ const bandRight = x(drawn[drawn.length - 1].endMs)
135
+ const bandW = bandRight - bandLeft
136
+ if (bandW <= 0) return null
137
+
138
+ const pacing = targetTempoSeconds != null && !prescribed
139
+ // Each run's elapsed time IS its own duration — the in-flight run's `endMs` is already
140
+ // "now" — so pacing needs no clock of its own.
141
+ const targetsMs = phaseTargetsMs(
142
+ drawn.map((seg) => seg.phase),
143
+ targetTempoSeconds
144
+ )
145
+
146
+ // Butt each run against the NEXT run's start (not its own end) so rounding between the
147
+ // two can never open a seam; the last run runs to the band edge.
148
+ const runs = drawn.map((seg, i) => {
149
+ const left = x(seg.startMs)
150
+ const right = i === drawn.length - 1 ? bandRight : x(drawn[i + 1].startMs)
151
+ const width = Math.max(0, right - left)
152
+ const targetMs = targetsMs[i]
153
+ const elapsedMs = seg.endMs - seg.startMs
154
+ return {
155
+ phase: seg.phase,
156
+ left,
157
+ width,
158
+ // Prescribed → nothing performed, so nothing filled. No target at all → the run reads
159
+ // complete rather than perpetually empty.
160
+ fillWidth: prescribed ? 0 : pacing ? width * phaseFillFraction(elapsedMs, targetMs) : width,
161
+ labelTone: pacing ? pacingTone(elapsedMs, targetMs) : labelColor,
162
+ }
163
+ })
164
+
165
+ return (
166
+ <g>
167
+ <defs>
168
+ <clipPath id={clipId}>
169
+ <rect x={bandLeft} y={top} width={bandW} height={height} rx={2} />
170
+ </clipPath>
171
+ {/* The WELL — dark under the top lip, falling off fast, with a faint light line on
172
+ the floor. SVG has no `inset` box-shadow, so a recess is a gradient. */}
173
+ <linearGradient id={wellId} x1="0" y1="0" x2="0" y2="1">
174
+ <stop offset="0%" stopColor={WELL_SHADE} stopOpacity={0.6} />
175
+ <stop offset="42%" stopColor={WELL_SHADE} stopOpacity={0.12} />
176
+ <stop offset="88%" stopColor={WELL_SHADE} stopOpacity={0.02} />
177
+ <stop offset="100%" stopColor={WELL_FLOOR_LIGHT} stopOpacity={0.07} />
178
+ </linearGradient>
179
+ </defs>
180
+ <g clipPath={`url(#${clipId})`}>
181
+ {/* the strip floor — the idle tone spans the rep so a pause is band, not a hole. */}
182
+ <rect
183
+ x={bandLeft}
184
+ y={top}
185
+ width={bandW}
186
+ height={height}
187
+ fill={PHASE_AXIS_COLOR.idle}
188
+ data-testid="ghost-band-floor"
189
+ />
190
+ {/* Each run: the muted base at FULL run width, then the pacing fill over the part
191
+ of it that has been earned. Both square inside the one clip — the fill is a
192
+ fill, never an inset, so no boundary can open a seam. */}
193
+ {runs.map((run, i) => (
194
+ <g key={i}>
195
+ <rect
196
+ x={run.left}
197
+ y={top}
198
+ width={run.width}
199
+ height={height}
200
+ fill={PHASE_AXIS_BASE_COLOR[run.phase]}
201
+ data-testid="ghost-band-base"
202
+ />
203
+ <rect
204
+ x={run.left}
205
+ y={top}
206
+ width={run.width}
207
+ height={height}
208
+ fill={`url(#${wellId})`}
209
+ data-testid="ghost-band-well"
210
+ />
211
+ <rect
212
+ x={run.left}
213
+ y={top}
214
+ width={run.fillWidth}
215
+ height={height}
216
+ fill={PHASE_AXIS_COLOR[run.phase]}
217
+ data-testid="ghost-band-fill"
218
+ />
219
+ </g>
220
+ ))}
221
+ {/* Labels last so a fill sweeping past never paints over the word. */}
222
+ {showLabels &&
223
+ runs.map((run, i) => {
224
+ const label = PHASE_LABEL[run.phase]
225
+ if (!label || !labelFits(label, run.width)) return null
226
+ return (
227
+ <text
228
+ key={i}
229
+ x={run.left + run.width / 2}
230
+ y={top + height / 2}
231
+ textAnchor="middle"
232
+ dominantBaseline="central"
233
+ fill={run.labelTone}
234
+ fontSize={8}
235
+ fontWeight={800}
236
+ letterSpacing={1}
237
+ fontFamily={FONT_UI}
238
+ >
239
+ {label}
240
+ </text>
241
+ )
242
+ })}
243
+ </g>
244
+ </g>
245
+ )
246
+ }