@titan-design/react-ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/index.d.mts +399 -4
  2. package/dist/index.d.ts +399 -4
  3. package/dist/index.js +2295 -895
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2279 -905
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  8. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +1974 -1740
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +1975 -1741
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +8 -3
  16. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  17. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  18. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  19. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  20. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  21. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  22. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  23. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  24. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  25. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  26. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  27. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  28. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  29. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  30. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  31. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  32. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  33. package/src/components/custom/Fatigue/README.md +93 -0
  34. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  35. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  36. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  37. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  38. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  39. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  40. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  41. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  42. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  43. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  44. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  45. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  46. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  47. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  48. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  49. package/src/components/custom/Fatigue/index.ts +41 -0
  50. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  51. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  52. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  56. package/src/components/custom/Workout/README.md +62 -16
  57. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  58. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  59. package/src/components/custom/Workout/SessionRail.tsx +50 -13
  60. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  61. package/src/components/custom/Workout/SetRow.tsx +9 -2
  62. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  63. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  64. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  65. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  66. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  68. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  69. package/src/components/custom/Workout/index.ts +3 -0
  70. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  71. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  72. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  73. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  74. package/src/components/custom/index.ts +1 -0
  75. package/src/test/raw-color-patterns.test.ts +71 -0
  76. package/src/theme/ColorPalettes.stories.tsx +871 -0
  77. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  78. package/src/theme/bar-paper.ts +59 -0
  79. package/src/theme/color-stories.coverage.test.ts +130 -0
  80. package/src/theme/color-story-kit.tsx +170 -0
  81. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  82. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  83. package/src/theme/ColorSystem.stories.tsx +0 -508
  84. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ phaseFillFraction,
4
+ pacingTone,
5
+ phaseTargetsMs,
6
+ prescribedSegments,
7
+ ON_TARGET_MS,
8
+ } from './tempo-pacing'
9
+ import {
10
+ PACING_TONE,
11
+ PACING_TONE_MIN_CONTRAST,
12
+ PHASE_AXIS_COLOR,
13
+ PHASE_AXIS_BASE_COLOR,
14
+ } from './fatigue-tokens'
15
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
16
+ import { primitiveRamps } from '../../../theme/tokens/primitives'
17
+ import type { SamplePhase } from './fatigue-model'
18
+
19
+ const t = getSemanticColors('dark')
20
+ const TEMPO: [number, number, number, number] = [2.6, 0.4, 0.95, 0.28]
21
+
22
+ /** WCAG 2.1 relative luminance / contrast ratio. */
23
+ function contrastRatio(a: string, b: string): number {
24
+ const lum = (hex: string): number => {
25
+ const h = hex.replace('#', '')
26
+ const chan = [0, 2, 4].map((i) => {
27
+ const c = parseInt(h.slice(i, i + 2), 16) / 255
28
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
29
+ })
30
+ return 0.2126 * chan[0] + 0.7152 * chan[1] + 0.0722 * chan[2]
31
+ }
32
+ const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x)
33
+ return (hi + 0.05) / (lo + 0.05)
34
+ }
35
+
36
+ describe('phaseFillFraction', () => {
37
+ it('is the elapsed share of the target', () => {
38
+ expect(phaseFillFraction(500, 1000)).toBeCloseTo(0.5, 5)
39
+ })
40
+
41
+ it('CAPS at full so an over-long phase does not overflow its run', () => {
42
+ expect(phaseFillFraction(4000, 1000)).toBe(1)
43
+ })
44
+
45
+ it('reads complete when there is no target to pace against', () => {
46
+ expect(phaseFillFraction(500, null)).toBe(1)
47
+ expect(phaseFillFraction(500, 0)).toBe(1)
48
+ })
49
+ })
50
+
51
+ describe('pacingTone', () => {
52
+ it('reads AHEAD while still short of target', () => {
53
+ expect(pacingTone(500, 2000)).toBe(PACING_TONE.ahead)
54
+ })
55
+
56
+ it('reads ON PACE within the on-target window either side', () => {
57
+ expect(pacingTone(2000, 2000)).toBe(PACING_TONE.onPace)
58
+ expect(pacingTone(2000 - ON_TARGET_MS, 2000)).toBe(PACING_TONE.onPace)
59
+ expect(pacingTone(2000 + ON_TARGET_MS, 2000)).toBe(PACING_TONE.onPace)
60
+ })
61
+
62
+ it('reads OVER once past the window', () => {
63
+ expect(pacingTone(2000 + ON_TARGET_MS + 1, 2000)).toBe(PACING_TONE.over)
64
+ })
65
+
66
+ it('stays neutral with no target', () => {
67
+ expect(pacingTone(500, null)).toBe(t['text-primary'])
68
+ })
69
+ })
70
+
71
+ describe('pacing tone legibility', () => {
72
+ // Only ECC and CON are labelled, so only those runs can put a colour behind a pacing
73
+ // tone — each over its fill and its muted base. `GhostBand.test.tsx` pins the fact that
74
+ // hold and idle stay unlabelled, which is what keeps this narrower set honest.
75
+ const LABELLED: SamplePhase[] = ['eccentric', 'concentric']
76
+ const backgrounds = LABELLED.flatMap((p) => [
77
+ [`${p} fill`, PHASE_AXIS_COLOR[p]] as const,
78
+ [`${p} base`, PHASE_AXIS_BASE_COLOR[p]] as const,
79
+ ])
80
+
81
+ for (const [toneName, tone] of Object.entries(PACING_TONE)) {
82
+ it(`keeps '${toneName}' legible on every background a label can sit on`, () => {
83
+ for (const [bgName, bg] of backgrounds) {
84
+ const ratio = contrastRatio(tone, bg)
85
+ expect(
86
+ ratio,
87
+ `${toneName} (${tone}) on ${bgName} (${bg}) = ${ratio.toFixed(2)}:1`
88
+ ).toBeGreaterThanOrEqual(PACING_TONE_MIN_CONTRAST)
89
+ }
90
+ })
91
+ }
92
+
93
+ it('rejects the status-error token the band used to borrow', () => {
94
+ // Proves the guard has teeth rather than passing by luck: red[600] on the concentric
95
+ // fill is 2.18:1, which is what made the label unreadable in the first place.
96
+ expect(contrastRatio(t['status-error'], PHASE_AXIS_COLOR.concentric)).toBeLessThan(
97
+ PACING_TONE_MIN_CONTRAST
98
+ )
99
+ })
100
+
101
+ it('sits at the DEEPEST ramp step that still clears the floor', () => {
102
+ // Guards the other direction — the tones should be as saturated as legibility allows,
103
+ // so a "make it lighter" edit has to justify itself. Derived from the ramp rather than
104
+ // hardcoded, so it catches a change in EITHER direction.
105
+ const STEPS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const
106
+ const worstOf = (c: string): number =>
107
+ Math.min(...backgrounds.map(([, bg]) => contrastRatio(c, bg)))
108
+
109
+ const cases = [
110
+ ['ahead', PACING_TONE.ahead, primitiveRamps.amber],
111
+ ['onPace', PACING_TONE.onPace, primitiveRamps.green],
112
+ ['over', PACING_TONE.over, primitiveRamps.red],
113
+ ] as const
114
+
115
+ for (const [name, configured, ramp] of cases) {
116
+ const deepestPassing = STEPS.filter((s) => worstOf(ramp[s]) >= PACING_TONE_MIN_CONTRAST).pop()
117
+ expect(
118
+ configured,
119
+ `${name}: deepest step clearing ${PACING_TONE_MIN_CONTRAST}:1 is ${deepestPassing}`
120
+ ).toBe(ramp[deepestPassing!])
121
+ }
122
+ })
123
+ })
124
+
125
+ describe('prescribedSegments', () => {
126
+ it('lays the prescribed rep out end to end at its target durations', () => {
127
+ expect(prescribedSegments(TEMPO)).toEqual([
128
+ { phase: 'eccentric', startMs: 0, endMs: 2600 },
129
+ { phase: 'hold', startMs: 2600, endMs: 3000 },
130
+ { phase: 'concentric', startMs: 3000, endMs: 3950 },
131
+ { phase: 'hold', startMs: 3950, endMs: 4230 },
132
+ ])
133
+ })
134
+
135
+ it('drops zero-length phases rather than drawing slivers', () => {
136
+ const noHolds = prescribedSegments([3, 0, 2, 0])
137
+ expect(noHolds.map((s) => s.phase)).toEqual(['eccentric', 'concentric'])
138
+ // The concentric still starts where the eccentric ended — no gap from the dropped hold.
139
+ expect(noHolds[1].startMs).toBe(noHolds[0].endMs)
140
+ })
141
+
142
+ it('has nothing to describe without a prescription', () => {
143
+ expect(prescribedSegments(null)).toEqual([])
144
+ })
145
+
146
+ it('round-trips against phaseTargetsMs — every run matches its own target', () => {
147
+ const segs = prescribedSegments(TEMPO)
148
+ const targets = phaseTargetsMs(
149
+ segs.map((s) => s.phase),
150
+ TEMPO
151
+ )
152
+ segs.forEach((s, i) => expect(s.endMs - s.startMs).toBe(targets[i]))
153
+ })
154
+ })
155
+
156
+ describe('phaseTargetsMs', () => {
157
+ it('maps a hold to BOTTOM before the concentric and TOP after it', () => {
158
+ const phases: SamplePhase[] = ['eccentric', 'hold', 'concentric', 'hold']
159
+ expect(phaseTargetsMs(phases, TEMPO)).toEqual([2600, 400, 950, 280])
160
+ })
161
+
162
+ it('gives idle no target — dead time never paces', () => {
163
+ const phases: SamplePhase[] = ['eccentric', 'idle', 'concentric']
164
+ expect(phaseTargetsMs(phases, TEMPO)[1]).toBeNull()
165
+ })
166
+
167
+ it('targets nothing at all when no tempo is prescribed', () => {
168
+ const phases: SamplePhase[] = ['eccentric', 'hold', 'concentric']
169
+ expect(phaseTargetsMs(phases, null)).toEqual([null, null, null])
170
+ })
171
+
172
+ it('treats a rep that opens on the concentric as having a TOP hold after it', () => {
173
+ const phases: SamplePhase[] = ['concentric', 'hold']
174
+ expect(phaseTargetsMs(phases, TEMPO)).toEqual([950, 280])
175
+ })
176
+ })
@@ -0,0 +1,112 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * Tempo PACING — how far through its prescribed duration a phase has run, and the
4
+ * semantic tone that says whether the lifter is ahead, on pace, or over.
5
+ *
6
+ * The rule this encodes (borrowed from `TempoDisplay`, which established it): the phase
7
+ * HUE carries phase identity and is deliberately non-semantic, so the pacing tone is free
8
+ * to use success/warning/error without the two colliding. Anything that paints a phase
9
+ * should take its identity colour from `PHASE_AXIS_COLOR` and its pacing colour from here.
10
+ *
11
+ * ⚠️ `TempoDisplay` currently carries its own private copy of this logic
12
+ * (`getTempoFillPct` / `activeNumberTone`). The two agree today and this module is the
13
+ * canonical one; migrating TempoDisplay onto it is deliberately left out of the change
14
+ * that introduced this file, so its rendering is untouched.
15
+ */
16
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
17
+ import { PACING_TONE } from './fatigue-tokens'
18
+ import type { SamplePhase, PhaseSegment } from './fatigue-model'
19
+
20
+ const t = getSemanticColors('dark')
21
+
22
+ /** ± this window (ms) around the target still counts as on pace. Matches TempoDisplay. */
23
+ export const ON_TARGET_MS = 100
24
+
25
+ /** The canonical tempo tuple order: `[ecc, pauseBottom, con, pauseTop]`, seconds. */
26
+ export type TempoTuple = [number, number, number, number]
27
+
28
+ /**
29
+ * How far a phase has run toward its prescribed duration, 0..1, CAPPED at 1.
30
+ *
31
+ * The cap is what makes an over-long phase read correctly: the fill stops at full and the
32
+ * TONE takes over to say "you are past target". Without a target there is nothing to pace
33
+ * against, so the phase reads complete rather than perpetually empty.
34
+ */
35
+ export function phaseFillFraction(elapsedMs: number, targetMs: number | null): number {
36
+ if (targetMs == null || targetMs <= 0) return 1
37
+ return Math.min(1, Math.max(0, elapsedMs / targetMs))
38
+ }
39
+
40
+ /**
41
+ * Pacing tone for a phase's LABEL: still short of target → ahead; within ±100 ms → on pace;
42
+ * past target → over. Keyed on time REMAINING, so it reads the same whether the phase is in
43
+ * flight or finished.
44
+ *
45
+ * Takes {@link PACING_TONE} rather than the `status-*` semantic tokens — the label sits on a
46
+ * saturated phase fill, where `status-error` measures 1.88:1. See the token's note.
47
+ */
48
+ export function pacingTone(elapsedMs: number, targetMs: number | null): string {
49
+ if (targetMs == null || targetMs <= 0) return t['text-primary']
50
+ const remainingMs = targetMs - elapsedMs
51
+ if (remainingMs > ON_TARGET_MS) return PACING_TONE.ahead
52
+ if (remainingMs >= -ON_TARGET_MS) return PACING_TONE.onPace
53
+ return PACING_TONE.over
54
+ }
55
+
56
+ /**
57
+ * The rep the prescription DESCRIBES, as phase runs — ecc, bottom hold, con, top hold, laid
58
+ * end to end at their target durations from t=0.
59
+ *
60
+ * This is the only place a band's geometry may come from the prescription rather than from
61
+ * actual samples, and it exists for exactly one case: nothing has been performed yet, so
62
+ * there is no actual time to lay out. Zero-length phases (a tempo with no holds) are
63
+ * dropped rather than drawn as slivers.
64
+ */
65
+ export function prescribedSegments(tempo: TempoTuple | null): PhaseSegment[] {
66
+ if (tempo == null) return []
67
+ const [ecc, pauseBottom, con, pauseTop] = tempo
68
+ const order: Array<[SamplePhase, number]> = [
69
+ ['eccentric', ecc],
70
+ ['hold', pauseBottom],
71
+ ['concentric', con],
72
+ ['hold', pauseTop],
73
+ ]
74
+ const out: PhaseSegment[] = []
75
+ let t = 0
76
+ for (const [phase, seconds] of order) {
77
+ const ms = seconds * 1000
78
+ if (ms > 0) out.push({ phase, startMs: t, endMs: t + ms })
79
+ t += ms
80
+ }
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * Target duration (ms) for each phase run of a rep, in stream order.
86
+ *
87
+ * A `hold` maps to `pauseBottom` or `pauseTop` by POSITION, not by name: the tuple
88
+ * distinguishes the two but the sample phase does not, so the first hold of a rep is the
89
+ * bottom and a hold after the concentric is the top. Phases with no prescribed duration
90
+ * (`idle` — undirected dead time) get `null` and never pace.
91
+ */
92
+ export function phaseTargetsMs(
93
+ phases: readonly SamplePhase[],
94
+ tempo: TempoTuple | null
95
+ ): Array<number | null> {
96
+ if (tempo == null) return phases.map(() => null)
97
+ const [eccS, pauseBottomS, conS, pauseTopS] = tempo
98
+ let seenConcentric = false
99
+ return phases.map((phase) => {
100
+ switch (phase) {
101
+ case 'eccentric':
102
+ return eccS * 1000
103
+ case 'concentric':
104
+ seenConcentric = true
105
+ return conS * 1000
106
+ case 'hold':
107
+ return (seenConcentric ? pauseTopS : pauseBottomS) * 1000
108
+ default:
109
+ return null
110
+ }
111
+ })
112
+ }