@titan-design/react-ui 0.3.0 → 0.5.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 (90) 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 +441 -97
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1958 -66
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +71 -738
  10. package/dist/index.d.ts +71 -738
  11. package/dist/index.js +888 -1583
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4515 -2348
  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 +6646 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6626 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +28 -2
  24. package/dist/theme/index.d.ts +28 -2
  25. package/dist/theme/index.js +37 -0
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +1880 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.mjs +573 -1
  30. package/dist/theme/tokens-css.mjs.map +1 -1
  31. package/package.json +9 -7
  32. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
  33. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  34. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  35. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  36. package/src/components/custom/Workout/BodyMap.tsx +7 -8
  37. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
  38. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  39. package/src/components/custom/Workout/ExerciseCard.tsx +17 -28
  40. package/src/components/custom/Workout/ExerciseDetailPage.tsx +38 -31
  41. package/src/components/custom/Workout/InputBar.tsx +10 -11
  42. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  43. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  44. package/src/components/custom/Workout/MesoCard.tsx +2 -6
  45. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  46. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  47. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -10
  48. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  49. package/src/components/custom/Workout/PrHistoryModal.tsx +14 -16
  50. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
  51. package/src/components/custom/Workout/README.md +50 -0
  52. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
  53. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  54. package/src/components/custom/Workout/RestTimer.tsx +55 -49
  55. package/src/components/custom/Workout/SetRow.tsx +20 -8
  56. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  57. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  58. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  59. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  60. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  61. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  62. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  63. package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
  64. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  65. package/src/components/custom/Workout/VelocityStrip.tsx +210 -43
  66. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  67. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  68. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  69. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  70. package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
  71. package/src/components/custom/Workout/icons.tsx +68 -0
  72. package/src/components/custom/Workout/index.ts +47 -47
  73. package/src/index.ts +3 -2
  74. package/src/pages.ts +61 -0
  75. package/src/test/nativewind-stub.ts +13 -0
  76. package/src/test/stories-smoke.test.tsx +50 -0
  77. package/src/theme/ThemeProvider.test.tsx +24 -0
  78. package/src/theme/ThemeProvider.tsx +50 -0
  79. package/src/theme/barrel.ts +53 -0
  80. package/src/theme/index.ts +8 -41
  81. package/src/theme/native-theming-guard.test.ts +117 -0
  82. package/src/theme/resolve-color.ts +19 -0
  83. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  84. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  85. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  86. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  87. package/dist/chunk-P7TSQUN6.mjs +0 -846
  88. package/dist/chunk-P7TSQUN6.mjs.map +0 -1
  89. package/dist/chunk-TOD2WQBG.mjs +0 -1048
  90. package/dist/chunk-TOD2WQBG.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 teal below the target ramp (< 75%)', () => {
48
+ render(<IntensityBar level={0.2} />)
49
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
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 teal just under the amber boundary', () => {
53
+ render(<IntensityBar level={0.74} />)
54
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
55
+ })
56
56
 
57
- it('clamps level below 0 to 0%', () => {
58
- render(<IntensityBar level={-0.5} />)
59
- expect(screen.getByLabelText('Intensity level: 0%')).toBeInTheDocument()
57
+ it('renders amber approaching the target (75-99%)', () => {
58
+ render(<IntensityBar level={0.75} />)
59
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
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: '#A62626' })
75
+ })
76
+
77
+ it('renders over-3 darkest red at 130%', () => {
78
+ render(<IntensityBar level={1.3} />)
79
+ expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#7A1C1C' })
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: '#A62626' })
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()
86
133
  })
87
134
 
88
- it('uses teal at the lower zone boundary', () => {
89
- render(<IntensityBar level={0.39} />)
90
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#14B8A6' })
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()
91
138
  })
92
139
 
93
- it('uses amber at the 0.4 boundary', () => {
94
- render(<IntensityBar level={0.4} />)
95
- expect(screen.getByTestId('intensity-fill')).toHaveStyle({ backgroundColor: '#FFB020' })
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()
143
+ })
144
+ })
145
+
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,20 @@
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'
4
11
 
5
12
  export type IntensityBarOrientation = 'vertical' | 'horizontal'
6
13
 
7
14
  export interface IntensityBarProps extends ViewProps {
8
- /** Current intensity level 0-1 */
15
+ /** Current intensity level; 1 == 100% of target. Values >1 render as over-target. */
9
16
  level: number
10
- /** MRV/overexertion threshold position 0-1 */
17
+ /** Target position 0-1. When the level sits at this target the fill gains an at-target glow. */
11
18
  threshold?: number
12
19
  /** Orientation */
13
20
  orientation?: IntensityBarOrientation
@@ -15,28 +22,57 @@ export interface IntensityBarProps extends ViewProps {
15
22
  size?: number
16
23
  /** Width for vertical (default 6px) or height for horizontal */
17
24
  thickness?: number
18
- /** Show threshold label ("MRV") */
25
+ /** Show the "MRV" threshold label under the bar */
19
26
  showThresholdLabel?: boolean
20
27
  className?: string
21
28
  }
22
29
 
23
- const TRACK_BG = 'rgba(255,255,255,0.06)'
24
- const THRESHOLD_COLOR = '#FFFFFF'
30
+ const TRACK_BG = '#333333'
25
31
  const LABEL_COLOR = '#6B7280'
32
+ const TARGET_LINE_COLOR = 'rgba(33, 150, 243, 0.5)'
33
+ const AT_TARGET_GLOW = '0 0 5px 1px rgba(33, 150, 243, 0.35), 0 0 10px 3px rgba(33, 150, 243, 0.15)'
26
34
 
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'
35
+ // Zone colors graded by TRUE percentage (level * 100).
36
+ const ZONE = {
37
+ building: '#14B8A6', // teal — below target ramp-up
38
+ approaching: '#FFB020', // amber — nearing target
39
+ target: '#FF7900', // brand — exactly at target
40
+ over1: '#D14343', // red — mild over-target
41
+ over2: '#A62626', // deep red — moderate over-target
42
+ over3: '#7A1C1C', // darkest red — severe over-target
43
+ } as const
44
+
45
+ type OverTier = 0 | 1 | 2 | 3
46
+
47
+ interface Zone {
48
+ color: string
49
+ over: OverTier
50
+ }
51
+
52
+ const BULGE_SIZE: Record<Exclude<OverTier, 0>, number> = { 1: 8, 2: 10, 3: 11 }
53
+
54
+ /** Map a true percentage to its fill color and over-target tier. */
55
+ function zoneForPct(pct: number): Zone {
56
+ if (pct >= 125) return { color: ZONE.over3, over: 3 }
57
+ if (pct >= 115) return { color: ZONE.over2, over: 2 }
58
+ if (pct > 100) return { color: ZONE.over1, over: 1 }
59
+ if (pct === 100) return { color: ZONE.target, over: 0 }
60
+ if (pct >= 75) return { color: ZONE.approaching, over: 0 }
61
+ return { color: ZONE.building, over: 0 }
32
62
  }
33
63
 
34
- function clamp01(value: number): number {
64
+ function clampFill(value: number): number {
35
65
  if (value < 0) return 0
36
66
  if (value > 1) return 1
37
67
  return value
38
68
  }
39
69
 
70
+ /** True when the level rests on the supplied target (within 5%). */
71
+ function isAtTarget(pct: number, threshold?: number): boolean {
72
+ if (threshold == null) return false
73
+ return Math.abs(pct - Math.round(threshold * 100)) <= 5
74
+ }
75
+
40
76
  export function IntensityBar({
41
77
  level,
42
78
  threshold,
@@ -48,21 +84,22 @@ export function IntensityBar({
48
84
  ...props
49
85
  }: IntensityBarProps) {
50
86
  const isVertical = orientation === 'vertical'
51
- const clampedLevel = clamp01(level)
52
- const percentage = Math.round(clampedLevel * 100)
53
- const zoneColor = getZoneColor(clampedLevel)
87
+ const fillLevel = clampFill(level)
88
+ const pct = Math.round(Math.max(0, level) * 100)
89
+ const zone = zoneForPct(pct)
90
+ const atTarget = isAtTarget(pct, threshold)
54
91
 
55
- const [fillAnim] = useState(() => new Animated.Value(clampedLevel))
92
+ const [fillAnim] = useState(() => new Animated.Value(fillLevel))
56
93
 
57
94
  useEffect(() => {
58
95
  const animation = Animated.timing(fillAnim, {
59
- toValue: clampedLevel,
96
+ toValue: fillLevel,
60
97
  duration: 250,
61
98
  useNativeDriver: false,
62
99
  })
63
100
  animation.start()
64
101
  return () => animation.stop()
65
- }, [clampedLevel, fillAnim])
102
+ }, [fillLevel, fillAnim])
66
103
 
67
104
  const fillSizePercent = fillAnim.interpolate({
68
105
  inputRange: [0, 1],
@@ -74,51 +111,50 @@ export function IntensityBar({
74
111
  : { width: size, height: thickness }
75
112
 
76
113
  const fillStyle = isVertical
114
+ ? { position: 'absolute' as const, bottom: 0, left: 0, right: 0, height: fillSizePercent }
115
+ : { position: 'absolute' as const, top: 0, bottom: 0, left: 0, width: fillSizePercent }
116
+
117
+ const bulgeSize = zone.over === 0 ? 0 : BULGE_SIZE[zone.over]
118
+ const bulgeStyle: ViewStyle = {
119
+ position: 'absolute',
120
+ width: bulgeSize,
121
+ height: bulgeSize,
122
+ borderRadius: bulgeSize / 2,
123
+ backgroundColor: zone.color,
124
+ zIndex: 1,
125
+ ...(isVertical
126
+ ? { top: 0, left: '50%', transform: [{ translateX: -bulgeSize / 2 }] }
127
+ : { right: 0, top: '50%', transform: [{ translateY: -bulgeSize / 2 }] }),
128
+ }
129
+
130
+ const targetPct: DimensionValue = `${clampFill(threshold ?? 0) * 100}%`
131
+ const targetLineStyle: ViewStyle = isVertical
77
132
  ? {
78
- position: 'absolute' as const,
79
- bottom: 0,
80
- left: 0,
81
- right: 0,
82
- height: fillSizePercent,
133
+ position: 'absolute',
134
+ bottom: targetPct,
135
+ left: -3,
136
+ right: -3,
137
+ height: 1.5,
138
+ backgroundColor: TARGET_LINE_COLOR,
139
+ zIndex: 4,
83
140
  }
84
141
  : {
85
- position: 'absolute' as const,
86
- top: 0,
87
- bottom: 0,
88
- left: 0,
89
- width: fillSizePercent,
142
+ position: 'absolute',
143
+ left: targetPct,
144
+ top: -3,
145
+ bottom: -3,
146
+ width: 1.5,
147
+ backgroundColor: TARGET_LINE_COLOR,
148
+ zIndex: 4,
90
149
  }
91
150
 
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
151
  return (
116
152
  <View
117
153
  className={className}
118
154
  style={{ alignItems: 'center' }}
119
155
  accessibilityRole="progressbar"
120
- accessibilityValue={{ min: 0, max: 100, now: percentage }}
121
- accessibilityLabel={`Intensity level: ${percentage}%`}
156
+ accessibilityValue={{ min: 0, max: 100, now: pct }}
157
+ accessibilityLabel={`Intensity level: ${pct}%`}
122
158
  testID="intensity-bar"
123
159
  {...props}
124
160
  >
@@ -136,16 +172,31 @@ export function IntensityBar({
136
172
  style={{
137
173
  ...fillStyle,
138
174
  borderRadius: 3,
139
- backgroundColor: zoneColor,
175
+ backgroundColor: zone.color,
140
176
  zIndex: 2,
177
+ ...(atTarget ? { boxShadow: AT_TARGET_GLOW } : null),
141
178
  }}
142
179
  testID="intensity-fill"
143
180
  />
144
181
 
145
- {threshold != null && (
146
- <View style={thresholdLineStyle} testID="intensity-threshold" />
147
- )}
182
+ {zone.over !== 0 && <View style={bulgeStyle} testID="intensity-bulge" />}
183
+
184
+ {threshold != null && <View style={targetLineStyle} testID="intensity-target" />}
148
185
  </View>
186
+
187
+ <Text
188
+ style={{
189
+ fontSize: 8,
190
+ color: LABEL_COLOR,
191
+ fontFamily: '"Nunito Sans", sans-serif',
192
+ marginTop: 6,
193
+ }}
194
+ accessibilityElementsHidden
195
+ testID="intensity-label"
196
+ >
197
+ {pct}%
198
+ </Text>
199
+
149
200
  {showThresholdLabel && threshold != null && (
150
201
  <Text
151
202
  style={{
@@ -153,7 +204,7 @@ export function IntensityBar({
153
204
  fontWeight: '500',
154
205
  color: LABEL_COLOR,
155
206
  fontFamily: 'Inter, sans-serif',
156
- marginTop: 6,
207
+ marginTop: 2,
157
208
  }}
158
209
  accessibilityElementsHidden
159
210
  testID="intensity-threshold-label"
@@ -9,10 +9,6 @@ const BRAND_PRIMARY = '#FF7900'
9
9
  const BRAND_PRIMARY_DARK = '#C45E00'
10
10
  const BRAND_PRIMARY_LIGHT = '#FFB066'
11
11
  const BORDER_DEFAULT = '#1F1F1F'
12
- // Theme-aware foregrounds: resolve to a readable color in both light and dark
13
- // (react-native-web passes the CSS var through to the inline style).
14
- const TEXT_PRIMARY = 'var(--color-text-primary)'
15
- const TEXT_SECONDARY = 'var(--color-text-secondary)'
16
12
 
17
13
  /** Gradient stops for the 3px top accent: dark -> primary -> light. */
18
14
  const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
@@ -119,11 +115,11 @@ export function MesoCard({
119
115
  <View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 10 }} testID="meso-card-body">
120
116
  <View className="flex-row items-center" style={{ gap: 8 }} testID="meso-card-header">
121
117
  <Text
118
+ className="text-text-primary"
122
119
  style={{
123
120
  fontSize: 15,
124
121
  fontFamily: '"Space Grotesk", sans-serif',
125
122
  fontWeight: '700',
126
- color: TEXT_PRIMARY,
127
123
  }}
128
124
  testID="meso-card-name"
129
125
  >
@@ -135,11 +131,11 @@ export function MesoCard({
135
131
  </View>
136
132
 
137
133
  <Text
134
+ className="text-text-secondary"
138
135
  style={{
139
136
  marginTop: 4,
140
137
  fontSize: 12,
141
138
  fontFamily: 'Inter, sans-serif',
142
- color: TEXT_SECONDARY,
143
139
  }}
144
140
  testID="meso-card-subheader"
145
141
  >
@@ -3,13 +3,11 @@ import { Fragment, type ReactNode } from 'react'
3
3
  import { View, Text, type ViewProps, type ViewStyle } from 'react-native'
4
4
  import { Card } from '../../ui/card'
5
5
  import { StatusDot } from './StatusDot'
6
+ import { resolveColor } from '../../../theme/resolve-color'
6
7
 
7
8
  const BRAND_PRIMARY = '#FF7900'
8
9
  const BRAND_PRIMARY_DARK = '#C45E00'
9
10
  const BRAND_PRIMARY_LIGHT = '#FFB066'
10
- const TEXT_PRIMARY = 'var(--color-text-primary)'
11
- const TEXT_SECONDARY = 'var(--color-text-secondary)'
12
- const TEXT_TERTIARY = 'var(--color-text-tertiary)'
13
11
 
14
12
  const SUCCESS = '#14B8A6'
15
13
  const WARNING = '#FFB020'
@@ -18,9 +16,12 @@ const ERROR = '#D14343'
18
16
  /** Gradient stops for the 3px top accent: dark -> primary -> light (matches MesoCard). */
19
17
  const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
20
18
 
21
- /** Card surface gradient: elevated -> raised at 135deg (theme-aware). */
22
- const CARD_GRADIENT =
23
- 'linear-gradient(135deg, var(--color-surface-elevated) 0%, var(--color-surface-raised) 100%)'
19
+ /**
20
+ * Card surface gradient: elevated -> raised at 135deg. Uses resolved dark-theme
21
+ * hexes (a gradient string can't carry a className token, and var() renders
22
+ * black on native).
23
+ */
24
+ const CARD_GRADIENT = `linear-gradient(135deg, ${resolveColor('surface-elevated')} 0%, ${resolveColor('surface-raised')} 100%)`
24
25
 
25
26
  /** Gauge track gradient (teal -> amber -> red) at 0.25 alpha. */
26
27
  const GAUGE_GRADIENT =
@@ -118,7 +119,8 @@ function renderCoachingText(text: string, highlights?: string[]): ReactNode {
118
119
  highlights.includes(part) ? (
119
120
  <Text
120
121
  key={`${part}-${index}`}
121
- style={{ fontWeight: '700', color: TEXT_PRIMARY }}
122
+ className="text-text-primary"
123
+ style={{ fontWeight: '700' }}
122
124
  >
123
125
  {part}
124
126
  </Text>
@@ -166,24 +168,24 @@ function MetricCell({ metric }: { metric: MesoStatusMetric }) {
166
168
  return (
167
169
  <View style={{ width: '48%' }} testID="meso-status-card-metric">
168
170
  <Text
171
+ className="text-text-tertiary"
169
172
  style={{
170
173
  fontSize: 10,
171
174
  fontFamily: 'Inter, sans-serif',
172
175
  fontWeight: '600',
173
176
  letterSpacing: 0.5,
174
177
  textTransform: 'uppercase',
175
- color: TEXT_TERTIARY,
176
178
  }}
177
179
  >
178
180
  {metric.label}
179
181
  </Text>
180
182
  <Text
183
+ className="text-text-primary"
181
184
  style={{
182
185
  marginTop: 2,
183
186
  fontSize: 13,
184
187
  fontFamily: 'Inter, sans-serif',
185
188
  fontWeight: '600',
186
- color: TEXT_PRIMARY,
187
189
  }}
188
190
  >
189
191
  {metric.value}
@@ -212,24 +214,24 @@ function Gauge({ gauge }: { gauge: MesoStatusGauge }) {
212
214
  accessibilityElementsHidden
213
215
  >
214
216
  <Text
217
+ className="text-text-secondary"
215
218
  style={{
216
219
  fontSize: 10,
217
220
  fontFamily: 'Inter, sans-serif',
218
221
  fontWeight: '500',
219
222
  letterSpacing: 0.5,
220
223
  textTransform: 'uppercase',
221
- color: TEXT_SECONDARY,
222
224
  }}
223
225
  >
224
226
  {gauge.label}
225
227
  </Text>
226
228
  {gauge.sublabel != null && (
227
229
  <Text
230
+ className="text-text-tertiary"
228
231
  style={{
229
232
  fontSize: 10,
230
233
  fontFamily: 'Inter, sans-serif',
231
234
  fontWeight: '500',
232
- color: TEXT_TERTIARY,
233
235
  }}
234
236
  >
235
237
  {gauge.sublabel}
@@ -261,6 +263,7 @@ function Gauge({ gauge }: { gauge: MesoStatusGauge }) {
261
263
  testID="meso-status-card-gauge-center"
262
264
  />
263
265
  <View
266
+ className="border-text-primary"
264
267
  style={{
265
268
  position: 'absolute',
266
269
  left: `${percentage}%`,
@@ -270,7 +273,6 @@ function Gauge({ gauge }: { gauge: MesoStatusGauge }) {
270
273
  height: 14,
271
274
  borderRadius: 9999,
272
275
  borderWidth: 2,
273
- borderColor: TEXT_PRIMARY,
274
276
  backgroundColor: markerColor,
275
277
  boxShadow: '0 0 4px rgba(0,0,0,0.5)',
276
278
  }}
@@ -349,12 +351,12 @@ export function MesoStatusCard({
349
351
  style={{ gap: 8 }}
350
352
  >
351
353
  <Text
354
+ className="text-text-primary"
352
355
  style={{
353
356
  flexShrink: 1,
354
357
  fontSize: 15,
355
358
  fontFamily: '"Space Grotesk", sans-serif',
356
359
  fontWeight: '700',
357
- color: TEXT_PRIMARY,
358
360
  }}
359
361
  testID="meso-status-card-name"
360
362
  >
@@ -363,11 +365,11 @@ export function MesoStatusCard({
363
365
  <StatusPill badge={statusBadge} />
364
366
  </View>
365
367
  <Text
368
+ className="text-text-secondary"
366
369
  style={{
367
370
  marginTop: 4,
368
371
  fontSize: 12,
369
372
  fontFamily: 'Inter, sans-serif',
370
- color: TEXT_SECONDARY,
371
373
  }}
372
374
  testID="meso-status-card-subtitle"
373
375
  >
@@ -408,11 +410,11 @@ export function MesoStatusCard({
408
410
  testID="meso-status-card-coaching"
409
411
  >
410
412
  <Text
413
+ className="text-text-secondary"
411
414
  style={{
412
415
  fontSize: 12,
413
416
  lineHeight: 17,
414
417
  fontFamily: 'Inter, sans-serif',
415
- color: TEXT_SECONDARY,
416
418
  }}
417
419
  >
418
420
  {renderCoachingText(coaching.text, coaching.highlights)}