@titan-design/react-ui 0.4.0 → 0.6.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 (150) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.js +232 -185
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1963 -11
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +193 -744
  10. package/dist/index.d.ts +193 -744
  11. package/dist/index.js +1551 -1856
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +10770 -8213
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/pages-D7Jlssvp.d.ts +791 -0
  16. package/dist/pages-MO0JCySL.d.mts +791 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.d.ts +4 -0
  19. package/dist/pages.js +6694 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6674 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +473 -211
  24. package/dist/theme/index.d.ts +473 -211
  25. package/dist/theme/index.js +341 -127
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +2085 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.js +148 -139
  30. package/dist/theme/tokens-css.js.map +1 -1
  31. package/dist/theme/tokens-css.mjs +582 -1
  32. package/dist/theme/tokens-css.mjs.map +1 -1
  33. package/package.json +11 -7
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
  35. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  36. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  37. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  38. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  39. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  40. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  41. package/src/components/custom/Typography/Typography.stories.tsx +45 -27
  42. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  43. package/src/components/custom/Typography/Typography.tsx +5 -0
  44. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
  45. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  46. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  47. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  48. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  49. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  50. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  51. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  52. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  53. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +19 -5
  56. package/src/components/custom/Workout/InputBar.tsx +2 -1
  57. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  58. package/src/components/custom/Workout/IntensityBar.tsx +113 -58
  59. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  60. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  61. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  62. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  63. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  64. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  65. package/src/components/custom/Workout/PrBadge.tsx +7 -4
  66. package/src/components/custom/Workout/PrHistoryModal.tsx +6 -5
  67. package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
  68. package/src/components/custom/Workout/README.md +50 -0
  69. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  70. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  71. package/src/components/custom/Workout/RestTimer.tsx +50 -44
  72. package/src/components/custom/Workout/SetRow.tsx +12 -2
  73. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  74. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  75. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  76. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  77. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  78. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  79. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  80. package/src/components/custom/Workout/TempoDisplay.tsx +109 -3
  81. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  82. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  83. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  84. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  85. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  86. package/src/components/custom/Workout/WeightBadge.tsx +8 -4
  87. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  88. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  89. package/src/components/custom/Workout/icons.tsx +7 -0
  90. package/src/components/custom/Workout/index.ts +38 -47
  91. package/src/components/icons/SvgIcon.tsx +50 -0
  92. package/src/components/icons/icons.stories.tsx +57 -0
  93. package/src/components/icons/icons.test.tsx +43 -0
  94. package/src/components/icons/icons.tsx +45 -0
  95. package/src/components/icons/index.ts +4 -0
  96. package/src/components/index.ts +6 -0
  97. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  98. package/src/components/shell/BrandLockup.test.tsx +23 -0
  99. package/src/components/shell/BrandLockup.tsx +47 -0
  100. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  101. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  102. package/src/components/shell/DeviceIndicator.tsx +52 -0
  103. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  104. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  105. package/src/components/shell/DeviceMenu.tsx +65 -0
  106. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  107. package/src/components/shell/DeviceRow.test.tsx +37 -0
  108. package/src/components/shell/DeviceRow.tsx +58 -0
  109. package/src/components/shell/README.md +86 -0
  110. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  111. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  112. package/src/components/shell/SessionStatePill.tsx +46 -0
  113. package/src/components/shell/TopBar.stories.tsx +49 -0
  114. package/src/components/shell/TopBar.test.tsx +36 -0
  115. package/src/components/shell/TopBar.tsx +89 -0
  116. package/src/components/shell/index.ts +8 -0
  117. package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
  118. package/src/components/ui/indicator/Indicator.test.tsx +19 -1
  119. package/src/components/ui/indicator/Indicator.tsx +45 -6
  120. package/src/components/ui/indicator/index.ts +1 -1
  121. package/src/components/ui/spinner/Spinner.tsx +3 -2
  122. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  123. package/src/index.ts +3 -2
  124. package/src/pages.ts +61 -0
  125. package/src/test/stories-smoke.test.tsx +50 -0
  126. package/src/theme/ColorSystem.stories.tsx +323 -0
  127. package/src/theme/Colors.stories.tsx +69 -1
  128. package/src/theme/barrel.ts +54 -0
  129. package/src/theme/config.ts +16 -3
  130. package/src/theme/extracted-colors-dataviz.ts +21 -0
  131. package/src/theme/extracted-colors-ui.ts +20 -0
  132. package/src/theme/global.css +64 -52
  133. package/src/theme/gradients.test.ts +25 -0
  134. package/src/theme/gradients.ts +36 -0
  135. package/src/theme/index.ts +8 -43
  136. package/src/theme/native-theming-guard.test.ts +117 -0
  137. package/src/theme/tokens/primitives.test.ts +232 -0
  138. package/src/theme/tokens/primitives.ts +247 -0
  139. package/src/theme/tokens/semantic.ts +93 -81
  140. package/src/theme/workout-tokens.ts +22 -21
  141. package/src/utils/avatar-color.ts +3 -3
  142. package/tailwind.config.js +10 -3
  143. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  144. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  145. package/dist/chunk-2SISKTWM.mjs +0 -1027
  146. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  147. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  148. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  149. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  150. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import { IntensityBar } from './IntensityBar'
5
5
 
6
+ const AT_TARGET_GLOW = '0 0 5px 1px rgba(33, 150, 243, 0.35), 0 0 10px 3px rgba(33, 150, 243, 0.15)'
7
+
6
8
  describe('IntensityBar', () => {
7
9
  it('renders the bar', () => {
8
10
  render(<IntensityBar level={0.5} />)
@@ -14,98 +16,148 @@ describe('IntensityBar', () => {
14
16
  expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
15
17
  })
16
18
 
17
- it('renders threshold line when threshold is provided', () => {
18
- render(<IntensityBar level={0.5} threshold={0.85} />)
19
- expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
20
- })
21
-
22
- it('does not render threshold line when threshold is omitted', () => {
19
+ it('has correct accessibility role', () => {
23
20
  render(<IntensityBar level={0.5} />)
24
- expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
21
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
25
22
  })
26
23
 
27
- it('shows MRV label when showThresholdLabel is true and threshold set', () => {
28
- render(<IntensityBar level={0.6} threshold={0.85} showThresholdLabel />)
29
- expect(screen.getByText('MRV')).toBeInTheDocument()
30
- })
24
+ describe('numeric percentage label', () => {
25
+ it('renders the level as a percentage under the bar', () => {
26
+ render(<IntensityBar level={0.2} />)
27
+ expect(screen.getByTestId('intensity-label')).toHaveTextContent('20%')
28
+ })
31
29
 
32
- it('hides MRV label when showThresholdLabel is false', () => {
33
- render(<IntensityBar level={0.6} threshold={0.85} />)
34
- expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
35
- })
30
+ it('renders over-target percentages above 100', () => {
31
+ render(<IntensityBar level={1.3} />)
32
+ expect(screen.getByTestId('intensity-label')).toHaveTextContent('130%')
33
+ })
36
34
 
37
- it('hides MRV label when threshold is omitted even if showThresholdLabel is true', () => {
38
- render(<IntensityBar level={0.6} showThresholdLabel />)
39
- expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
40
- })
35
+ it('mirrors the label in the accessibility value', () => {
36
+ render(<IntensityBar level={1.1} />)
37
+ expect(screen.getByLabelText('Intensity level: 110%')).toBeInTheDocument()
38
+ })
41
39
 
42
- it('has correct accessibility role', () => {
43
- render(<IntensityBar level={0.5} />)
44
- expect(screen.getByRole('progressbar')).toBeInTheDocument()
40
+ it('floors a negative level to 0%', () => {
41
+ render(<IntensityBar level={-0.5} />)
42
+ expect(screen.getByTestId('intensity-label')).toHaveTextContent('0%')
43
+ })
45
44
  })
46
45
 
47
- it('maps level 0-1 to percentage in accessibility label', () => {
48
- render(<IntensityBar level={0.75} />)
49
- expect(screen.getByLabelText('Intensity level: 75%')).toBeInTheDocument()
50
- })
46
+ describe('zone colors graded by true percentage', () => {
47
+ it('renders green below the target ramp (< 75%)', () => {
48
+ render(<IntensityBar level={0.2} />)
49
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#2ED573' })
50
+ })
51
51
 
52
- it('clamps level above 1 to 100%', () => {
53
- render(<IntensityBar level={1.5} />)
54
- expect(screen.getByLabelText('Intensity level: 100%')).toBeInTheDocument()
55
- })
52
+ it('stays green just under the amber boundary', () => {
53
+ render(<IntensityBar level={0.74} />)
54
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#2ED573' })
55
+ })
56
+
57
+ it('renders amber approaching the target (75-99%)', () => {
58
+ render(<IntensityBar level={0.75} />)
59
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#F9B415' })
60
+ })
61
+
62
+ it('renders the brand target color exactly at 100%', () => {
63
+ render(<IntensityBar level={1} />)
64
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FF7900' })
65
+ })
66
+
67
+ it('renders over-1 red just past target (110%)', () => {
68
+ render(<IntensityBar level={1.1} />)
69
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
70
+ })
71
+
72
+ it('renders over-2 deep red at 120%', () => {
73
+ render(<IntensityBar level={1.2} />)
74
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#A4221C' })
75
+ })
56
76
 
57
- it('clamps level below 0 to 0%', () => {
58
- render(<IntensityBar level={-0.5} />)
59
- expect(screen.getByLabelText('Intensity level: 0%')).toBeInTheDocument()
77
+ it('renders over-3 darkest red at 130%', () => {
78
+ render(<IntensityBar level={1.3} />)
79
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#7E1002' })
80
+ })
60
81
  })
61
82
 
62
- it('renders horizontal orientation', () => {
63
- render(<IntensityBar level={0.5} orientation="horizontal" />)
64
- expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
83
+ describe('over-target bulge', () => {
84
+ it('renders a bulge when the level exceeds target', () => {
85
+ render(<IntensityBar level={1.1} />)
86
+ expect(screen.getByTestId('intensity-bulge')).toBeInTheDocument()
87
+ })
88
+
89
+ it('colors the bulge to match the over-target zone', () => {
90
+ render(<IntensityBar level={1.2} />)
91
+ expect(screen.getByTestId('intensity-bulge')).toHaveStyle({ backgroundColor: '#A4221C' })
92
+ })
93
+
94
+ it('does not render a bulge at or below target', () => {
95
+ render(<IntensityBar level={1} />)
96
+ expect(screen.queryByTestId('intensity-bulge')).not.toBeInTheDocument()
97
+ })
65
98
  })
66
99
 
67
- it('accepts custom size and thickness', () => {
68
- render(<IntensityBar level={0.5} size={32} thickness={8} />)
69
- expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
100
+ describe('target line', () => {
101
+ it('renders the target line when threshold is provided', () => {
102
+ render(<IntensityBar level={0.5} threshold={0.5} />)
103
+ expect(screen.getByTestId('intensity-target')).toBeInTheDocument()
104
+ })
105
+
106
+ it('does not render the target line when threshold is omitted', () => {
107
+ render(<IntensityBar level={0.5} />)
108
+ expect(screen.queryByTestId('intensity-target')).not.toBeInTheDocument()
109
+ })
70
110
  })
71
111
 
72
- describe('fill zones', () => {
73
- it('renders teal fill for low levels (0-0.4)', () => {
74
- render(<IntensityBar level={0.3} />)
75
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
112
+ describe('at-target glow', () => {
113
+ it('applies the blue glow when the level rests on the target', () => {
114
+ render(<IntensityBar level={0.5} threshold={0.5} />)
115
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ boxShadow: AT_TARGET_GLOW })
116
+ })
117
+
118
+ it('does not glow when the level is away from the target', () => {
119
+ render(<IntensityBar level={0.2} threshold={0.85} />)
120
+ expect(screen.getByTestId('intensity-fill')).not.toHaveStyle({ boxShadow: AT_TARGET_GLOW })
76
121
  })
77
122
 
78
- it('renders amber fill for moderate levels (0.4-0.7)', () => {
123
+ it('does not glow when no threshold is provided', () => {
79
124
  render(<IntensityBar level={0.5} />)
80
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
125
+ expect(screen.getByTestId('intensity-fill')).not.toHaveStyle({ boxShadow: AT_TARGET_GLOW })
81
126
  })
127
+ })
82
128
 
83
- it('renders red fill for high levels (0.7-1.0)', () => {
84
- render(<IntensityBar level={0.85} />)
85
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
129
+ describe('MRV threshold label', () => {
130
+ it('shows MRV when showThresholdLabel is true and threshold set', () => {
131
+ render(<IntensityBar level={0.6} threshold={0.85} showThresholdLabel />)
132
+ expect(screen.getByText('MRV')).toBeInTheDocument()
133
+ })
134
+
135
+ it('hides MRV when showThresholdLabel is false', () => {
136
+ render(<IntensityBar level={0.6} threshold={0.85} />)
137
+ expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
86
138
  })
87
139
 
88
- it('uses teal at the lower zone boundary', () => {
89
- render(<IntensityBar level={0.39} />)
90
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
140
+ it('hides MRV when threshold is omitted even if showThresholdLabel is true', () => {
141
+ render(<IntensityBar level={0.6} showThresholdLabel />)
142
+ expect(screen.queryByTestId('intensity-threshold-label')).not.toBeInTheDocument()
91
143
  })
144
+ })
92
145
 
93
- it('uses amber at the 0.4 boundary', () => {
94
- render(<IntensityBar level={0.4} />)
95
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
146
+ describe('geometry', () => {
147
+ it('renders horizontal orientation', () => {
148
+ render(<IntensityBar level={0.5} orientation="horizontal" />)
149
+ expect(screen.getByTestId('intensity-fill')).toBeInTheDocument()
96
150
  })
97
151
 
98
- it('uses red at the 0.7 boundary', () => {
99
- render(<IntensityBar level={0.7} />)
100
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#D14343' })
152
+ it('accepts custom size and thickness', () => {
153
+ render(<IntensityBar level={0.5} size={36} thickness={8} />)
154
+ expect(screen.getByTestId('intensity-bar')).toBeInTheDocument()
101
155
  })
102
156
  })
103
157
 
104
158
  describe('accessibility', () => {
105
159
  it('has no accessibility violations', async () => {
106
- const { container } = render(
107
- <IntensityBar level={0.5} threshold={0.85} showThresholdLabel />,
108
- )
160
+ const { container } = render(<IntensityBar level={0.5} threshold={0.85} showThresholdLabel />)
109
161
  const results = await axe(container)
110
162
  expect(results).toHaveNoViolations()
111
163
  })
@@ -1,13 +1,24 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useEffect, useState } from 'react'
3
- import { View, Text, Animated, type ViewProps, type ViewStyle } from 'react-native'
3
+ import {
4
+ View,
5
+ Text,
6
+ Animated,
7
+ type ViewProps,
8
+ type ViewStyle,
9
+ type DimensionValue,
10
+ } from 'react-native'
11
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
12
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
13
+
14
+ const t = getSemanticColors('dark')
4
15
 
5
16
  export type IntensityBarOrientation = 'vertical' | 'horizontal'
6
17
 
7
18
  export interface IntensityBarProps extends ViewProps {
8
- /** Current intensity level 0-1 */
19
+ /** Current intensity level; 1 == 100% of target. Values >1 render as over-target. */
9
20
  level: number
10
- /** MRV/overexertion threshold position 0-1 */
21
+ /** Target position 0-1. When the level sits at this target the fill gains an at-target glow. */
11
22
  threshold?: number
12
23
  /** Orientation */
13
24
  orientation?: IntensityBarOrientation
@@ -15,28 +26,57 @@ export interface IntensityBarProps extends ViewProps {
15
26
  size?: number
16
27
  /** Width for vertical (default 6px) or height for horizontal */
17
28
  thickness?: number
18
- /** Show threshold label ("MRV") */
29
+ /** Show the "MRV" threshold label under the bar */
19
30
  showThresholdLabel?: boolean
20
31
  className?: string
21
32
  }
22
33
 
23
- const TRACK_BG = 'rgba(255,255,255,0.06)'
24
- const THRESHOLD_COLOR = '#FFFFFF'
34
+ const TRACK_BG = '#333333'
25
35
  const LABEL_COLOR = '#6B7280'
36
+ const TARGET_LINE_COLOR = 'rgba(33, 150, 243, 0.5)'
37
+ const AT_TARGET_GLOW = '0 0 5px 1px rgba(33, 150, 243, 0.35), 0 0 10px 3px rgba(33, 150, 243, 0.15)'
38
+
39
+ // Zone colors graded by TRUE percentage (level * 100).
40
+ const ZONE = {
41
+ building: t['status-success'], // below target ramp-up
42
+ approaching: t['status-warning'], // nearing target
43
+ target: t['brand-primary'], // exactly at target
44
+ over1: t['status-error'], // mild over-target
45
+ over2: WORKOUT_TOKENS.intensity.over2, // moderate over-target
46
+ over3: WORKOUT_TOKENS.intensity.over3, // severe over-target
47
+ } as const
48
+
49
+ type OverTier = 0 | 1 | 2 | 3
26
50
 
27
- /** Fill color by intensity zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
28
- function getZoneColor(level: number): string {
29
- if (level >= 0.7) return '#D14343'
30
- if (level >= 0.4) return '#FFB020'
31
- return '#14B8A6'
51
+ interface Zone {
52
+ color: string
53
+ over: OverTier
32
54
  }
33
55
 
34
- function clamp01(value: number): number {
56
+ const BULGE_SIZE: Record<Exclude<OverTier, 0>, number> = { 1: 8, 2: 10, 3: 11 }
57
+
58
+ /** Map a true percentage to its fill color and over-target tier. */
59
+ function zoneForPct(pct: number): Zone {
60
+ if (pct >= 125) return { color: ZONE.over3, over: 3 }
61
+ if (pct >= 115) return { color: ZONE.over2, over: 2 }
62
+ if (pct > 100) return { color: ZONE.over1, over: 1 }
63
+ if (pct === 100) return { color: ZONE.target, over: 0 }
64
+ if (pct >= 75) return { color: ZONE.approaching, over: 0 }
65
+ return { color: ZONE.building, over: 0 }
66
+ }
67
+
68
+ function clampFill(value: number): number {
35
69
  if (value < 0) return 0
36
70
  if (value > 1) return 1
37
71
  return value
38
72
  }
39
73
 
74
+ /** True when the level rests on the supplied target (within 5%). */
75
+ function isAtTarget(pct: number, threshold?: number): boolean {
76
+ if (threshold == null) return false
77
+ return Math.abs(pct - Math.round(threshold * 100)) <= 5
78
+ }
79
+
40
80
  export function IntensityBar({
41
81
  level,
42
82
  threshold,
@@ -48,21 +88,22 @@ export function IntensityBar({
48
88
  ...props
49
89
  }: IntensityBarProps) {
50
90
  const isVertical = orientation === 'vertical'
51
- const clampedLevel = clamp01(level)
52
- const percentage = Math.round(clampedLevel * 100)
53
- const zoneColor = getZoneColor(clampedLevel)
91
+ const fillLevel = clampFill(level)
92
+ const pct = Math.round(Math.max(0, level) * 100)
93
+ const zone = zoneForPct(pct)
94
+ const atTarget = isAtTarget(pct, threshold)
54
95
 
55
- const [fillAnim] = useState(() => new Animated.Value(clampedLevel))
96
+ const [fillAnim] = useState(() => new Animated.Value(fillLevel))
56
97
 
57
98
  useEffect(() => {
58
99
  const animation = Animated.timing(fillAnim, {
59
- toValue: clampedLevel,
100
+ toValue: fillLevel,
60
101
  duration: 250,
61
102
  useNativeDriver: false,
62
103
  })
63
104
  animation.start()
64
105
  return () => animation.stop()
65
- }, [clampedLevel, fillAnim])
106
+ }, [fillLevel, fillAnim])
66
107
 
67
108
  const fillSizePercent = fillAnim.interpolate({
68
109
  inputRange: [0, 1],
@@ -74,51 +115,50 @@ export function IntensityBar({
74
115
  : { width: size, height: thickness }
75
116
 
76
117
  const fillStyle = isVertical
118
+ ? { position: 'absolute' as const, bottom: 0, left: 0, right: 0, height: fillSizePercent }
119
+ : { position: 'absolute' as const, top: 0, bottom: 0, left: 0, width: fillSizePercent }
120
+
121
+ const bulgeSize = zone.over === 0 ? 0 : BULGE_SIZE[zone.over]
122
+ const bulgeStyle: ViewStyle = {
123
+ position: 'absolute',
124
+ width: bulgeSize,
125
+ height: bulgeSize,
126
+ borderRadius: bulgeSize / 2,
127
+ backgroundColor: zone.color,
128
+ zIndex: 1,
129
+ ...(isVertical
130
+ ? { top: 0, left: '50%', transform: [{ translateX: -bulgeSize / 2 }] }
131
+ : { right: 0, top: '50%', transform: [{ translateY: -bulgeSize / 2 }] }),
132
+ }
133
+
134
+ const targetPct: DimensionValue = `${clampFill(threshold ?? 0) * 100}%`
135
+ const targetLineStyle: ViewStyle = isVertical
77
136
  ? {
78
- position: 'absolute' as const,
79
- bottom: 0,
80
- left: 0,
81
- right: 0,
82
- height: fillSizePercent,
137
+ position: 'absolute',
138
+ bottom: targetPct,
139
+ left: -3,
140
+ right: -3,
141
+ height: 1.5,
142
+ backgroundColor: TARGET_LINE_COLOR,
143
+ zIndex: 4,
83
144
  }
84
145
  : {
85
- position: 'absolute' as const,
86
- top: 0,
87
- bottom: 0,
88
- left: 0,
89
- width: fillSizePercent,
146
+ position: 'absolute',
147
+ left: targetPct,
148
+ top: -3,
149
+ bottom: -3,
150
+ width: 1.5,
151
+ backgroundColor: TARGET_LINE_COLOR,
152
+ zIndex: 4,
90
153
  }
91
154
 
92
- const thresholdLineStyle: ViewStyle | undefined =
93
- threshold != null
94
- ? isVertical
95
- ? {
96
- position: 'absolute' as const,
97
- bottom: `${clamp01(threshold) * 100}%`,
98
- left: -2,
99
- right: -2,
100
- height: 1,
101
- backgroundColor: THRESHOLD_COLOR,
102
- zIndex: 4,
103
- }
104
- : {
105
- position: 'absolute' as const,
106
- left: `${clamp01(threshold) * 100}%`,
107
- top: -2,
108
- bottom: -2,
109
- width: 1,
110
- backgroundColor: THRESHOLD_COLOR,
111
- zIndex: 4,
112
- }
113
- : undefined
114
-
115
155
  return (
116
156
  <View
117
157
  className={className}
118
158
  style={{ alignItems: 'center' }}
119
159
  accessibilityRole="progressbar"
120
- accessibilityValue={{ min: 0, max: 100, now: percentage }}
121
- accessibilityLabel={`Intensity level: ${percentage}%`}
160
+ accessibilityValue={{ min: 0, max: 100, now: pct }}
161
+ accessibilityLabel={`Intensity level: ${pct}%`}
122
162
  testID="intensity-bar"
123
163
  {...props}
124
164
  >
@@ -136,16 +176,31 @@ export function IntensityBar({
136
176
  style={{
137
177
  ...fillStyle,
138
178
  borderRadius: 3,
139
- backgroundColor: zoneColor,
179
+ backgroundColor: zone.color,
140
180
  zIndex: 2,
181
+ ...(atTarget ? { boxShadow: AT_TARGET_GLOW } : null),
141
182
  }}
142
183
  testID="intensity-fill"
143
184
  />
144
185
 
145
- {threshold != null && (
146
- <View style={thresholdLineStyle} testID="intensity-threshold" />
147
- )}
186
+ {zone.over !== 0 && <View style={bulgeStyle} testID="intensity-bulge" />}
187
+
188
+ {threshold != null && <View style={targetLineStyle} testID="intensity-target" />}
148
189
  </View>
190
+
191
+ <Text
192
+ style={{
193
+ fontSize: 8,
194
+ color: LABEL_COLOR,
195
+ fontFamily: '"Nunito Sans", sans-serif',
196
+ marginTop: 6,
197
+ }}
198
+ accessibilityElementsHidden
199
+ testID="intensity-label"
200
+ >
201
+ {pct}%
202
+ </Text>
203
+
149
204
  {showThresholdLabel && threshold != null && (
150
205
  <Text
151
206
  style={{
@@ -153,7 +208,7 @@ export function IntensityBar({
153
208
  fontWeight: '500',
154
209
  color: LABEL_COLOR,
155
210
  fontFamily: 'Inter, sans-serif',
156
- marginTop: 6,
211
+ marginTop: 2,
157
212
  }}
158
213
  accessibilityElementsHidden
159
214
  testID="intensity-threshold-label"
@@ -4,10 +4,15 @@ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-n
4
4
  import { Card } from '../../ui/card'
5
5
  import { Badge } from '../../ui/badge'
6
6
  import { WeekRow, type WeekRowProps } from './WeekRow'
7
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
8
+ import {
9
+ MESO_ACCENT_GRADIENT_DARK,
10
+ MESO_ACCENT_GRADIENT_LIGHT,
11
+ } from '../../../theme/extracted-colors-dataviz'
7
12
 
8
- const BRAND_PRIMARY = '#FF7900'
9
- const BRAND_PRIMARY_DARK = '#C45E00'
10
- const BRAND_PRIMARY_LIGHT = '#FFB066'
13
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
14
+ const BRAND_PRIMARY_DARK = MESO_ACCENT_GRADIENT_DARK
15
+ const BRAND_PRIMARY_LIGHT = MESO_ACCENT_GRADIENT_LIGHT
11
16
  const BORDER_DEFAULT = '#1F1F1F'
12
17
 
13
18
  /** Gradient stops for the 3px top accent: dark -> primary -> light. */
@@ -40,7 +40,7 @@ describe('MesoProgressBar', () => {
40
40
  it('applies completed color at 0.3 alpha', () => {
41
41
  render(<MesoProgressBar mesos={mesos} activeMesoId={null} onMesoPress={vi.fn()} />)
42
42
  expect(screen.getByTestId('meso-segment-inner-m1')).toHaveStyle({
43
- backgroundColor: 'rgba(20,184,166,0.3)',
43
+ backgroundColor: 'rgba(46,213,115,0.3)',
44
44
  })
45
45
  })
46
46
 
@@ -1,8 +1,9 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useState } from 'react'
3
3
  import { View, Pressable, Animated, type ViewProps } from 'react-native'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
 
5
- const BRAND_PRIMARY = '#FF7900'
6
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
6
7
 
7
8
  export type MesoStatus = 'completed' | 'current' | 'upcoming'
8
9
 
@@ -26,7 +27,7 @@ export interface MesoProgressBarProps extends ViewProps {
26
27
  }
27
28
 
28
29
  const segmentBgColors: Record<MesoStatus, string> = {
29
- completed: 'rgba(20,184,166,0.3)',
30
+ completed: 'rgba(46,213,115,0.3)',
30
31
  current: 'rgba(255,121,0,0.5)',
31
32
  upcoming: 'rgba(255,255,255,0.06)',
32
33
  }
@@ -4,14 +4,21 @@ import { View, Text, type ViewProps, type ViewStyle } from 'react-native'
4
4
  import { Card } from '../../ui/card'
5
5
  import { StatusDot } from './StatusDot'
6
6
  import { resolveColor } from '../../../theme/resolve-color'
7
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
8
+ import {
9
+ MESO_ACCENT_GRADIENT_DARK,
10
+ MESO_ACCENT_GRADIENT_LIGHT,
11
+ } from '../../../theme/extracted-colors-dataviz'
7
12
 
8
- const BRAND_PRIMARY = '#FF7900'
9
- const BRAND_PRIMARY_DARK = '#C45E00'
10
- const BRAND_PRIMARY_LIGHT = '#FFB066'
13
+ const t = getSemanticColors('dark')
11
14
 
12
- const SUCCESS = '#14B8A6'
13
- const WARNING = '#FFB020'
14
- const ERROR = '#D14343'
15
+ const BRAND_PRIMARY = t['brand-primary']
16
+ const BRAND_PRIMARY_DARK = MESO_ACCENT_GRADIENT_DARK
17
+ const BRAND_PRIMARY_LIGHT = MESO_ACCENT_GRADIENT_LIGHT
18
+
19
+ const SUCCESS = t['status-success']
20
+ const WARNING = t['status-warning']
21
+ const ERROR = t['status-error']
15
22
 
16
23
  /** Gradient stops for the 3px top accent: dark -> primary -> light (matches MesoCard). */
17
24
  const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
@@ -25,7 +32,7 @@ const CARD_GRADIENT = `linear-gradient(135deg, ${resolveColor('surface-elevated'
25
32
 
26
33
  /** Gauge track gradient (teal -> amber -> red) at 0.25 alpha. */
27
34
  const GAUGE_GRADIENT =
28
- 'linear-gradient(90deg, rgba(20,184,166,0.25) 0%, rgba(255,176,32,0.25) 50%, rgba(209,67,67,0.25) 100%)'
35
+ 'linear-gradient(90deg, rgba(46,213,115,0.25) 0%, rgba(249,180,21,0.25) 50%, rgba(209,67,67,0.25) 100%)'
29
36
 
30
37
  export type MesoStatusBadgeVariant = 'success' | 'warning' | 'error'
31
38
 
@@ -33,8 +40,8 @@ const STATUS_VARIANTS: Record<
33
40
  MesoStatusBadgeVariant,
34
41
  { bg: string; border: string; text: string }
35
42
  > = {
36
- success: { bg: 'rgba(20,184,166,0.15)', border: 'rgba(20,184,166,0.3)', text: SUCCESS },
37
- warning: { bg: 'rgba(255,176,32,0.15)', border: 'rgba(255,176,32,0.3)', text: WARNING },
43
+ success: { bg: 'rgba(46,213,115,0.15)', border: 'rgba(46,213,115,0.3)', text: SUCCESS },
44
+ warning: { bg: 'rgba(249,180,21,0.15)', border: 'rgba(249,180,21,0.3)', text: WARNING },
38
45
  error: { bg: 'rgba(209,67,67,0.15)', border: 'rgba(209,67,67,0.25)', text: ERROR },
39
46
  }
40
47
 
@@ -400,9 +407,9 @@ export function MesoStatusCard({
400
407
  {coaching != null && (
401
408
  <View
402
409
  style={{
403
- backgroundColor: 'rgba(255,176,32,0.06)',
410
+ backgroundColor: 'rgba(249,180,21,0.06)',
404
411
  borderWidth: 1,
405
- borderColor: 'rgba(255,176,32,0.15)',
412
+ borderColor: 'rgba(249,180,21,0.15)',
406
413
  borderRadius: 8,
407
414
  paddingVertical: 10,
408
415
  paddingHorizontal: 12,
@@ -427,9 +434,9 @@ export function MesoStatusCard({
427
434
  className="flex-row items-center"
428
435
  style={{
429
436
  gap: 8,
430
- backgroundColor: 'rgba(20,184,166,0.06)',
437
+ backgroundColor: 'rgba(46,213,115,0.06)',
431
438
  borderWidth: 1,
432
- borderColor: 'rgba(20,184,166,0.2)',
439
+ borderColor: 'rgba(46,213,115,0.2)',
433
440
  borderRadius: 8,
434
441
  paddingVertical: 10,
435
442
  paddingHorizontal: 12,
@@ -1,5 +1,8 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+
5
+ const t = getSemanticColors('dark')
3
6
 
4
7
  // Aliases for spec compatibility: under=behind, maintenance=ontrack, productive=target
5
8
  export type VolumeStatus = 'untrained' | 'behind' | 'ontrack' | 'target' | 'over'
@@ -13,10 +16,10 @@ export interface MuscleGroupChipProps extends ViewProps {
13
16
 
14
17
  const dotColorMap: Record<VolumeStatus, string> = {
15
18
  untrained: '#2C2C2C',
16
- behind: '#406D87',
17
- ontrack: '#14B8A6',
18
- target: '#FF7900',
19
- over: '#D14343',
19
+ behind: t['brand-secondary'],
20
+ ontrack: t['status-success'],
21
+ target: t['brand-primary'],
22
+ over: t['status-error'],
20
23
  }
21
24
 
22
25
  export function MuscleGroupChip({
@@ -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 } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
4
5
 
5
6
  export interface PlaceholderStripProps extends ViewProps {
6
7
  width?: number | string
@@ -18,14 +19,12 @@ function SingleStrip({
18
19
  }: Omit<PlaceholderStripProps, 'mode' | 'segments'>) {
19
20
  return (
20
21
  <View
21
- className={cn('border-border-strong', className)}
22
+ className={className}
22
23
  style={[
23
24
  {
24
25
  height: 3,
25
- backgroundColor: 'transparent',
26
- borderWidth: 1,
27
- borderStyle: 'dashed',
28
- borderRadius: 1,
26
+ backgroundColor: WORKOUT_TOKENS.placeholder.fill,
27
+ borderRadius: 2,
29
28
  opacity: 0.5,
30
29
  },
31
30
  width != null ? { width: width as number } : { flex: 1 },
@@ -59,13 +58,10 @@ function SegmentedStrip({
59
58
  {Array.from({ length: segments }, (_, i) => (
60
59
  <View
61
60
  key={i}
62
- className="border-border-strong"
63
61
  style={{
64
62
  flex: 1,
65
63
  height: 3,
66
- backgroundColor: 'transparent',
67
- borderWidth: 1,
68
- borderStyle: 'dashed',
64
+ backgroundColor: WORKOUT_TOKENS.placeholder.fill,
69
65
  borderRadius: 1,
70
66
  minWidth: 4,
71
67
  }}