@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.
Files changed (190) hide show
  1. package/dist/bodymap.js +173 -343
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +173 -343
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +416 -18
  6. package/dist/index.d.ts +416 -18
  7. package/dist/index.js +2512 -1307
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2480 -1299
  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 +1644 -1575
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1645 -1576
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +409 -415
  20. package/dist/theme/index.d.ts +409 -415
  21. package/dist/theme/index.js +234 -408
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +227 -399
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +141 -140
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +141 -140
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/arch/Architecture.stories.tsx +9 -9
  31. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  32. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  33. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  34. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  35. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  36. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  37. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  38. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  39. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  40. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  41. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  42. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  43. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  45. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  46. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  47. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  48. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  49. package/src/components/custom/Fatigue/README.md +93 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  51. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  52. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  53. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  54. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  55. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  56. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  57. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  58. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  59. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  60. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  61. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  62. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  63. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  64. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  65. package/src/components/custom/Fatigue/index.ts +41 -0
  66. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  67. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  68. package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
  69. package/src/components/custom/Table/Table.tsx +2 -2
  70. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
  71. package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
  72. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  73. package/src/components/custom/Workout/BodyMap.tsx +2 -2
  74. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
  75. package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
  76. package/src/components/custom/Workout/DeviationBar.tsx +3 -2
  77. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  78. package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
  79. package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
  80. package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
  81. package/src/components/custom/Workout/InputBar.tsx +2 -2
  82. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
  83. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
  84. package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
  85. package/src/components/custom/Workout/MesoCard.tsx +3 -1
  86. package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
  87. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  88. package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
  89. package/src/components/custom/Workout/README.md +62 -16
  90. package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
  91. package/src/components/custom/Workout/RestTimer.tsx +2 -2
  92. package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
  93. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  94. package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
  95. package/src/components/custom/Workout/SessionHeader.tsx +1 -1
  96. package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
  97. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  98. package/src/components/custom/Workout/SessionRail.tsx +63 -18
  99. package/src/components/custom/Workout/SetBar.tsx +3 -3
  100. package/src/components/custom/Workout/SetRow.test.tsx +12 -9
  101. package/src/components/custom/Workout/SetRow.tsx +9 -2
  102. package/src/components/custom/Workout/StatusDot.tsx +5 -4
  103. package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
  104. package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
  105. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  106. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  107. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  108. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  109. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  110. package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
  111. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  112. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
  113. package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
  114. package/src/components/custom/Workout/WeightBadge.tsx +2 -1
  115. package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
  116. package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
  117. package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
  118. package/src/components/custom/Workout/index.ts +3 -0
  119. package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
  120. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  121. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  122. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  123. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  124. package/src/components/custom/index.ts +1 -0
  125. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  126. package/src/components/custom/stepper/Stepper.tsx +3 -3
  127. package/src/components/shell/DeviceMenu.tsx +2 -2
  128. package/src/components/shell/SideNav.tsx +1 -1
  129. package/src/components/shell/TopBar.tsx +1 -1
  130. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  131. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  132. package/src/components/ui/avatar/Avatar.tsx +2 -2
  133. package/src/components/ui/badge/Badge.tsx +2 -2
  134. package/src/components/ui/card/Card.tsx +3 -3
  135. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  136. package/src/components/ui/collapse/Collapse.tsx +1 -1
  137. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  138. package/src/components/ui/drawer/Drawer.tsx +3 -3
  139. package/src/components/ui/form-field/FormField.tsx +1 -1
  140. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  141. package/src/components/ui/menu/Menu.tsx +2 -2
  142. package/src/components/ui/pill/Pill.tsx +2 -2
  143. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  144. package/src/components/ui/popover/Popover.tsx +1 -1
  145. package/src/components/ui/progress/Progress.tsx +3 -3
  146. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  147. package/src/components/ui/radio/Radio.tsx +4 -4
  148. package/src/components/ui/select/Select.tsx +3 -3
  149. package/src/components/ui/surface/Surface.stories.tsx +3 -3
  150. package/src/components/ui/surface/Surface.test.tsx +32 -19
  151. package/src/components/ui/surface/Surface.tsx +3 -3
  152. package/src/components/ui/surface/SurfaceContext.ts +19 -29
  153. package/src/components/ui/surface/surface.contract.test.ts +57 -44
  154. package/src/components/ui/switch/Switch.tsx +1 -1
  155. package/src/components/ui/tabs/Tabs.tsx +2 -2
  156. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
  157. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
  158. package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
  159. package/src/stories/PresetShowcase.stories.tsx +21 -21
  160. package/src/test/raw-color-patterns.test.ts +71 -0
  161. package/src/theme/ColorPalettes.stories.tsx +874 -0
  162. package/src/theme/ColorPrimitives.stories.tsx +348 -0
  163. package/src/theme/Depth.stories.tsx +312 -0
  164. package/src/theme/DepthCalibration.stories.tsx +655 -0
  165. package/src/theme/ThemeProvider.tsx +1 -1
  166. package/src/theme/barrel.ts +7 -14
  167. package/src/theme/color-stories.coverage.test.ts +125 -0
  168. package/src/theme/color-story-kit.tsx +170 -0
  169. package/src/theme/color-utils.ts +197 -0
  170. package/src/theme/config.ts +0 -8
  171. package/src/theme/depth-calibration.test.tsx +97 -0
  172. package/src/theme/elevation.test.ts +62 -1
  173. package/src/theme/elevation.ts +34 -8
  174. package/src/theme/global.css +47 -53
  175. package/src/theme/grey-ramp.test.ts +166 -0
  176. package/src/theme/materials.test.ts +106 -0
  177. package/src/theme/materials.ts +211 -0
  178. package/src/theme/presets/audiobook.ts +4 -4
  179. package/src/theme/presets/types.ts +3 -3
  180. package/src/theme/semantic-contrast.test.ts +83 -0
  181. package/src/theme/tokens/primitives.ts +62 -115
  182. package/src/theme/tokens/semantic.ts +130 -82
  183. package/src/utils/colors.ts +1 -1
  184. package/tailwind.config.js +2 -5
  185. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  186. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  187. package/src/theme/ColorSystem.stories.tsx +0 -508
  188. package/src/theme/Colors.stories.tsx +0 -323
  189. package/src/theme/shadows.stories.tsx +0 -523
  190. package/src/theme/shadows.ts +0 -439
@@ -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
+ }
@@ -64,7 +64,7 @@ export function Sidebar({
64
64
  <View
65
65
  style={{ width: currentWidth }}
66
66
  className={cn(
67
- 'h-full flex-col bg-surface-base border-r border-border transition-all duration-200',
67
+ 'h-full flex-col bg-surface-base border-r border-hairline transition-all duration-200',
68
68
  className
69
69
  )}
70
70
  {...props}
@@ -85,7 +85,7 @@ export interface SidebarHeaderProps {
85
85
  */
86
86
  export function SidebarHeader({ children, className }: SidebarHeaderProps) {
87
87
  return (
88
- <View className={cn('px-4 py-4 border-b border-border-subtle', className)}>
88
+ <View className={cn('px-4 py-4 border-b border-hairline-subtle', className)}>
89
89
  {children}
90
90
  </View>
91
91
  )
@@ -120,7 +120,7 @@ export interface SidebarFooterProps {
120
120
  */
121
121
  export function SidebarFooter({ children, className }: SidebarFooterProps) {
122
122
  return (
123
- <View className={cn('px-2 py-4 border-t border-border-subtle', className)}>
123
+ <View className={cn('px-2 py-4 border-t border-hairline-subtle', className)}>
124
124
  {children}
125
125
  </View>
126
126
  )
@@ -252,6 +252,6 @@ export interface SidebarDividerProps {
252
252
  */
253
253
  export function SidebarDivider({ className }: SidebarDividerProps) {
254
254
  return (
255
- <View className={cn('h-px mx-4 my-2 bg-border-subtle', className)} />
255
+ <View className={cn('h-px mx-4 my-2 bg-hairline-subtle', className)} />
256
256
  )
257
257
  }
@@ -590,7 +590,7 @@ export function TableSelectAllCell({ className }: TableSelectAllCellProps) {
590
590
  ? 'bg-brand-primary border-brand-primary'
591
591
  : someSelected
592
592
  ? 'bg-brand-primary-subtle border-brand-primary'
593
- : 'border-border-strong bg-transparent'
593
+ : 'border-hairline-strong bg-transparent'
594
594
  )}
595
595
  >
596
596
  {(allSelected || someSelected) && (
@@ -629,7 +629,7 @@ export function TableSelectCell({ rowId, className }: TableSelectCellProps) {
629
629
  'w-5 h-5 rounded border-2 items-center justify-center',
630
630
  isSelected
631
631
  ? 'bg-brand-primary border-brand-primary'
632
- : 'border-border-strong bg-transparent web:hover:border-brand-primary'
632
+ : 'border-hairline-strong bg-transparent web:hover:border-brand-primary'
633
633
  )}
634
634
  >
635
635
  {isSelected && (
@@ -273,7 +273,7 @@ function WorkoutHeader({
273
273
  </View>
274
274
  </View>
275
275
  <View
276
- className="bg-border"
276
+ className="bg-hairline"
277
277
  style={{ height: 4, borderRadius: 2 }}
278
278
  testID="active-workout-page-progress-track"
279
279
  >
@@ -305,7 +305,7 @@ function ExerciseList({ groups, focusedId, onToggle }: ExerciseListProps) {
305
305
  return (
306
306
  <View
307
307
  key={group.exercise.id}
308
- className="bg-surface-elevated border-border"
308
+ className="bg-surface-elevated border-hairline"
309
309
  style={{
310
310
  borderWidth: 1,
311
311
  borderRadius: 12,
@@ -399,7 +399,7 @@ export function ActiveWorkoutPage({
399
399
 
400
400
  {(showRest || showInput) && (
401
401
  <View
402
- style={{ borderTopWidth: 1, borderTopColor: resolveColor('border-default') }}
402
+ style={{ borderTopWidth: 1, borderTopColor: resolveColor('hairline-default') }}
403
403
  testID="active-workout-page-bottom-bar"
404
404
  >
405
405
  {showRest && rest != null ? (
@@ -3,6 +3,11 @@ import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import { Text, View } from 'react-native'
5
5
  import { BaseBadge } from './BaseBadge'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+
8
+ // Read from the token, not pinned: these used to be hand-copied hexes and
9
+ // silently desynced when borders became alpha hairlines (TD-07.16).
10
+ const T = getSemanticColors('dark')
6
11
 
7
12
  describe('BaseBadge', () => {
8
13
  it('renders children', () => {
@@ -12,7 +17,7 @@ describe('BaseBadge', () => {
12
17
 
13
18
  it('defaults to the plain variant border color', () => {
14
19
  render(<BaseBadge testID="bb"><Text>x</Text></BaseBadge>)
15
- expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: '#1F1F1F' })
20
+ expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: T['hairline-default'] })
16
21
  })
17
22
 
18
23
  it('applies the pr variant border color', () => {
@@ -30,7 +30,7 @@ export const baseBadgeSizeConfig: Record<
30
30
  }
31
31
 
32
32
  const variantColors: Record<BaseBadgeVariant, { backgroundColor: string; borderColor: string }> = {
33
- plain: { backgroundColor: t['surface-raised'], borderColor: '#1F1F1F' },
33
+ plain: { backgroundColor: t['surface-raised'], borderColor: t['hairline-default'] },
34
34
  pr: {
35
35
  backgroundColor: 'rgba(255, 121, 0, 0.12)',
36
36
  borderColor: 'rgba(255, 121, 0, 0.3)',
@@ -278,7 +278,7 @@ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
278
278
  borderRadius: 9999,
279
279
  backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
280
280
  borderWidth: 1,
281
- borderColor: active ? BRAND_PRIMARY : resolveColor('border-strong'),
281
+ borderColor: active ? BRAND_PRIMARY : resolveColor('hairline-strong'),
282
282
  }}
283
283
  testID={`body-map-toggle-${side}`}
284
284
  >
@@ -324,7 +324,7 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
324
324
  paddingVertical: 3,
325
325
  borderRadius: 9999,
326
326
  borderWidth: 1,
327
- borderColor: highlighted ? BRAND_PRIMARY : resolveColor('border-default'),
327
+ borderColor: highlighted ? BRAND_PRIMARY : resolveColor('hairline-default'),
328
328
  }}
329
329
  testID={`body-map-muscle-${entry.key}`}
330
330
  >
@@ -220,7 +220,7 @@ export function BodyMapDetailPanel({
220
220
  testID="body-map-detail-panel-handle"
221
221
  >
222
222
  <View
223
- className="bg-border-strong"
223
+ className="bg-hairline-strong"
224
224
  style={{ width: 40, height: 4, borderRadius: 2 }}
225
225
  accessibilityElementsHidden
226
226
  />
@@ -380,7 +380,7 @@ export function BodyMapDetailPanel({
380
380
  {contributingExercises!.map((exercise, index) => (
381
381
  <View
382
382
  key={`${exercise.name}-${index}`}
383
- className="flex-row items-center justify-between bg-surface-raised border-border"
383
+ className="flex-row items-center justify-between bg-surface-raised border-hairline"
384
384
  style={{
385
385
  gap: 10,
386
386
  paddingVertical: 8,
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import { render, screen } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import { DeviationBar } from './DeviationBar'
5
+ import { greyRamp } from '../../../theme/tokens/primitives'
5
6
 
6
7
  describe('DeviationBar', () => {
7
8
  it('renders the bar and dot', () => {
@@ -62,7 +63,7 @@ describe('DeviationBar', () => {
62
63
  const { getByTestId } = render(<DeviationBar deviation={0} />)
63
64
  const dot = getByTestId('deviation-dot')
64
65
  expect(dot).toHaveStyle({ width: '8px', height: '8px' })
65
- expect(dot).toHaveStyle({ borderTopWidth: '1.5px', borderTopColor: '#F3F4F6' })
66
+ expect(dot).toHaveStyle({ borderTopWidth: '1.5px', borderTopColor: greyRamp[50] })
66
67
  expect(dot).toHaveStyle({ boxShadow: '0 0 4px rgba(0,0,0,0.5)' })
67
68
  })
68
69
 
@@ -1,6 +1,7 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, type ViewProps, type ViewStyle } from 'react-native'
3
3
  import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+ import { greyRamp } from '../../../theme/tokens/primitives'
4
5
 
5
6
  const t = getSemanticColors('dark')
6
7
 
@@ -13,7 +14,7 @@ export interface DeviationBarProps extends ViewProps {
13
14
  function getDotColor(deviation: number): string {
14
15
  const abs = Math.abs(deviation)
15
16
  if (deviation < -0.3) return t['status-success']
16
- if (abs <= 0.3) return '#6B7280'
17
+ if (abs <= 0.3) return greyRamp[500]
17
18
  if (abs <= 0.7) return t['status-warning']
18
19
  return t['status-error']
19
20
  }
@@ -68,7 +69,7 @@ export function DeviationBar({
68
69
  height: dotSize,
69
70
  borderRadius: 9999,
70
71
  borderWidth: 1.5,
71
- borderColor: '#F3F4F6',
72
+ borderColor: greyRamp[50],
72
73
  boxShadow: '0 0 4px rgba(0,0,0,0.5)',
73
74
  backgroundColor: getDotColor(clamped),
74
75
  left: Math.max(0, Math.min(dotPosition - dotSize / 2, resolvedWidth - dotSize)),