@titan-design/react-ui 0.2.7 → 0.4.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.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +3014 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1088 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/chunk-2SISKTWM.mjs +1027 -0
- package/dist/chunk-2SISKTWM.mjs.map +1 -0
- package/dist/{chunk-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/{chunk-3VLOBS6O.mjs → chunk-SNVKL5WZ.mjs} +42 -5
- package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
- package/dist/index.d.mts +416 -250
- package/dist/index.d.ts +416 -250
- package/dist/index.js +1567 -1123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1491 -2201
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +288 -2
- package/dist/theme/index.d.ts +288 -2
- package/dist/theme/index.js +159 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -2
- package/dist/theme/tokens-css.d.mts +5 -3
- package/dist/theme/tokens-css.d.ts +5 -3
- package/dist/theme/tokens-css.js +318 -9
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +1 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +10 -1
- package/src/bodymap.ts +37 -0
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +442 -0
- package/src/components/custom/Workout/BodyMap.tsx +7 -9
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -29
- package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
- package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +24 -26
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +624 -0
- package/src/components/custom/Workout/InputBar.tsx +10 -12
- package/src/components/custom/Workout/MesoCard.tsx +2 -4
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
- package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
- package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -4
- package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +418 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +13 -12
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +16 -19
- package/src/components/custom/Workout/StatusDot.tsx +1 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
- package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
- package/src/components/custom/Workout/TempoBar.tsx +215 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +312 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +19 -16
- package/src/components/custom/Workout/WeekRow.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -6
- package/src/components/custom/Workout/index.ts +80 -32
- package/src/test/nativewind-stub.ts +13 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +50 -0
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/resolve-color.ts +19 -0
- package/src/theme/tokens-css.ts +5 -3
- package/src/theme/workout-tokens.ts +12 -14
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-3VLOBS6O.mjs.map +0 -1
- package/dist/chunk-5SSKGS6E.mjs.map +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
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
3
|
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
5
|
+
import { formatVelocity } from '../../../utils/workout-format'
|
|
4
6
|
|
|
5
7
|
export interface VelocityStripProps extends ViewProps {
|
|
6
8
|
velocities: number[]
|
|
@@ -12,12 +14,8 @@ export interface VelocityStripProps extends ViewProps {
|
|
|
12
14
|
className?: string
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
yellow: '#ffd43b',
|
|
18
|
-
orange: '#ffa502',
|
|
19
|
-
red: '#ff4757',
|
|
20
|
-
}
|
|
17
|
+
// Canonical scale shared with SetRow's RPE color (see theme/workout-tokens.ts).
|
|
18
|
+
const VEL_COLORS = WORKOUT_TOKENS.scale
|
|
21
19
|
|
|
22
20
|
export function getVelocityZoneColor(velocity: number): string {
|
|
23
21
|
if (velocity >= 1.0) return 'vel-green'
|
|
@@ -153,7 +151,9 @@ export function VelocityStrip({
|
|
|
153
151
|
|
|
154
152
|
const stripContent = (
|
|
155
153
|
<Animated.View
|
|
156
|
-
className={className
|
|
154
|
+
className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' ')}
|
|
157
157
|
style={[
|
|
158
158
|
{
|
|
159
159
|
height: heightAnim,
|
|
@@ -164,7 +164,6 @@ export function VelocityStrip({
|
|
|
164
164
|
paddingBottom: expanded ? 24 : 0,
|
|
165
165
|
paddingHorizontal: expanded ? 6 : 0,
|
|
166
166
|
paddingVertical: expanded ? undefined : 8,
|
|
167
|
-
backgroundColor: expanded ? '#1C1C1C' : 'transparent',
|
|
168
167
|
overflow: expanded ? 'visible' : 'hidden',
|
|
169
168
|
},
|
|
170
169
|
]}
|
|
@@ -178,7 +177,10 @@ export function VelocityStrip({
|
|
|
178
177
|
>
|
|
179
178
|
{velocities.map((v, i) => {
|
|
180
179
|
const zone = getVelocityZoneColor(v)
|
|
181
|
-
|
|
180
|
+
// Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
|
|
181
|
+
// this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
|
|
182
|
+
const barHeightPct =
|
|
183
|
+
maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
|
|
182
184
|
|
|
183
185
|
const bar = (
|
|
184
186
|
<View
|
|
@@ -188,10 +190,11 @@ export function VelocityStrip({
|
|
|
188
190
|
{expanded && (
|
|
189
191
|
<Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
|
|
190
192
|
<Text
|
|
191
|
-
|
|
193
|
+
className="text-text-secondary"
|
|
194
|
+
style={{ fontSize: 8, fontWeight: '600' }}
|
|
192
195
|
testID={`velocity-label-${i}`}
|
|
193
196
|
>
|
|
194
|
-
{v
|
|
197
|
+
{formatVelocity(v)}
|
|
195
198
|
</Text>
|
|
196
199
|
</Animated.View>
|
|
197
200
|
)}
|
|
@@ -210,7 +213,7 @@ export function VelocityStrip({
|
|
|
210
213
|
}
|
|
211
214
|
testID={`velocity-bar-${i}`}
|
|
212
215
|
accessibilityRole="image"
|
|
213
|
-
accessibilityLabel={`Rep ${i + 1}: ${v
|
|
216
|
+
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
|
|
214
217
|
/>
|
|
215
218
|
</View>
|
|
216
219
|
)
|
|
@@ -222,7 +225,7 @@ export function VelocityStrip({
|
|
|
222
225
|
style={{ flex: 1, height: '100%' }}
|
|
223
226
|
onPress={() => onRepPress(i, v)}
|
|
224
227
|
accessibilityRole="button"
|
|
225
|
-
accessibilityLabel={`Rep ${i + 1}: ${v
|
|
228
|
+
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
|
|
226
229
|
testID={`velocity-bar-pressable-${i}`}
|
|
227
230
|
>
|
|
228
231
|
{bar}
|
|
@@ -247,18 +250,18 @@ export function VelocityStrip({
|
|
|
247
250
|
testID="velocity-info-row"
|
|
248
251
|
>
|
|
249
252
|
<Text
|
|
253
|
+
className="text-text-secondary"
|
|
250
254
|
style={{
|
|
251
255
|
fontSize: 10,
|
|
252
|
-
color: '#9CA3AF',
|
|
253
256
|
fontFamily: 'Inter, sans-serif',
|
|
254
257
|
}}
|
|
255
258
|
>
|
|
256
|
-
{meanZone} {'\u00B7'} {meanVelocity
|
|
259
|
+
{meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
|
|
257
260
|
</Text>
|
|
258
261
|
<Text
|
|
262
|
+
className="text-text-secondary"
|
|
259
263
|
style={{
|
|
260
264
|
fontSize: 10,
|
|
261
|
-
color: '#9CA3AF',
|
|
262
265
|
fontFamily: 'Inter, sans-serif',
|
|
263
266
|
...getLossStyle(loss),
|
|
264
267
|
}}
|
|
@@ -100,11 +100,12 @@ export function WeekRow({
|
|
|
100
100
|
/>
|
|
101
101
|
)}
|
|
102
102
|
<Text
|
|
103
|
+
className={isCurrent ? undefined : 'text-text-primary'}
|
|
103
104
|
style={{
|
|
104
105
|
fontSize: 13,
|
|
105
106
|
fontWeight: '700',
|
|
106
107
|
fontFamily: 'Inter, sans-serif',
|
|
107
|
-
color: isCurrent ? BRAND_PRIMARY :
|
|
108
|
+
color: isCurrent ? BRAND_PRIMARY : undefined,
|
|
108
109
|
}}
|
|
109
110
|
accessibilityElementsHidden
|
|
110
111
|
>
|
|
@@ -43,9 +43,6 @@ export interface WorkoutCardProps extends ViewProps {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const COLORS = {
|
|
46
|
-
textPrimary: '#F3F4F6',
|
|
47
|
-
textSecondary: '#9CA3AF',
|
|
48
|
-
textTertiary: '#6B7280',
|
|
49
46
|
statusSuccess: '#14B8A6',
|
|
50
47
|
brandPrimary: '#FF7900',
|
|
51
48
|
borderDefault: '#1F1F1F',
|
|
@@ -122,11 +119,11 @@ export function WorkoutCard({
|
|
|
122
119
|
<View style={{ padding: 14 }} testID="workout-card-body">
|
|
123
120
|
<View className="flex-row items-center" testID="workout-card-header">
|
|
124
121
|
<Text
|
|
122
|
+
className="text-text-primary"
|
|
125
123
|
style={{
|
|
126
124
|
fontSize: 15,
|
|
127
125
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
128
126
|
fontWeight: '700',
|
|
129
|
-
color: COLORS.textPrimary,
|
|
130
127
|
}}
|
|
131
128
|
testID="workout-card-name"
|
|
132
129
|
>
|
|
@@ -134,10 +131,10 @@ export function WorkoutCard({
|
|
|
134
131
|
</Text>
|
|
135
132
|
<View className="flex-1" />
|
|
136
133
|
<Text
|
|
134
|
+
className="text-text-tertiary"
|
|
137
135
|
style={{
|
|
138
136
|
fontSize: 11,
|
|
139
137
|
fontFamily: 'Inter, sans-serif',
|
|
140
|
-
color: COLORS.textTertiary,
|
|
141
138
|
}}
|
|
142
139
|
testID="workout-card-date"
|
|
143
140
|
>
|
|
@@ -146,11 +143,11 @@ export function WorkoutCard({
|
|
|
146
143
|
</View>
|
|
147
144
|
|
|
148
145
|
<Text
|
|
146
|
+
className="text-text-secondary"
|
|
149
147
|
style={{
|
|
150
148
|
marginTop: 4,
|
|
151
149
|
fontSize: 12,
|
|
152
150
|
fontFamily: 'Inter, sans-serif',
|
|
153
|
-
color: COLORS.textSecondary,
|
|
154
151
|
}}
|
|
155
152
|
testID="workout-card-stats"
|
|
156
153
|
>
|
|
@@ -4,15 +4,20 @@ export {
|
|
|
4
4
|
type BaseBadgeVariant,
|
|
5
5
|
type BaseBadgeSize,
|
|
6
6
|
} from './BaseBadge'
|
|
7
|
-
export {
|
|
8
|
-
WeightBadge,
|
|
9
|
-
type WeightBadgeProps,
|
|
10
|
-
type WeightBadgeSize,
|
|
11
|
-
} from './WeightBadge'
|
|
7
|
+
export { WeightBadge, type WeightBadgeProps, type WeightBadgeSize } from './WeightBadge'
|
|
12
8
|
export { PrBadge, type PrBadgeProps, type PRType } from './PrBadge'
|
|
13
9
|
export { StatusDot, type StatusDotVariant, type StatusDotProps } from './StatusDot'
|
|
14
10
|
export { PlaceholderStrip, type PlaceholderStripProps } from './PlaceholderStrip'
|
|
15
11
|
export { TempoDisplay, type TempoDisplayProps } from './TempoDisplay'
|
|
12
|
+
export {
|
|
13
|
+
TempoBar,
|
|
14
|
+
type TempoBarProps,
|
|
15
|
+
type TempoPhaseKey,
|
|
16
|
+
type TempoPacingState,
|
|
17
|
+
TEMPO_PACING,
|
|
18
|
+
getTempoPacingState,
|
|
19
|
+
getTempoFillPct,
|
|
20
|
+
} from './TempoBar'
|
|
16
21
|
export { DeviationBar, type DeviationBarProps } from './DeviationBar'
|
|
17
22
|
export { IntensityBar, type IntensityBarProps } from './IntensityBar'
|
|
18
23
|
export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
|
|
@@ -24,11 +29,7 @@ export {
|
|
|
24
29
|
calculateVelocityLoss,
|
|
25
30
|
calculateMeanVelocity,
|
|
26
31
|
} from './VelocityStrip'
|
|
27
|
-
export {
|
|
28
|
-
MuscleGroupChip,
|
|
29
|
-
type MuscleGroupChipProps,
|
|
30
|
-
type VolumeStatus,
|
|
31
|
-
} from './MuscleGroupChip'
|
|
32
|
+
export { MuscleGroupChip, type MuscleGroupChipProps, type VolumeStatus } from './MuscleGroupChip'
|
|
32
33
|
export { Sparkline, type SparklineProps } from './Sparkline'
|
|
33
34
|
export { SetRow, type SetRowProps, type SetRowMode } from './SetRow'
|
|
34
35
|
export { InputBar, type InputBarProps } from './InputBar'
|
|
@@ -49,11 +50,7 @@ export {
|
|
|
49
50
|
type WorkoutMuscleGroup,
|
|
50
51
|
type WorkoutMuscleVolumeStatus,
|
|
51
52
|
} from './WorkoutCard'
|
|
52
|
-
export {
|
|
53
|
-
MesoCard,
|
|
54
|
-
type MesoCardProps,
|
|
55
|
-
type MesoVolumeHeatmapEntry,
|
|
56
|
-
} from './MesoCard'
|
|
53
|
+
export { MesoCard, type MesoCardProps, type MesoVolumeHeatmapEntry } from './MesoCard'
|
|
57
54
|
export {
|
|
58
55
|
PrHistoryModal,
|
|
59
56
|
type PrHistoryModalProps,
|
|
@@ -91,25 +88,76 @@ export {
|
|
|
91
88
|
type WorkoutDot,
|
|
92
89
|
type WorkoutDotStatus,
|
|
93
90
|
} from './CapacityBandChart'
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
// BodyMap / BodyMapDetailPanel / TrainingStatusPage depend on
|
|
92
|
+
// `react-native-body-highlighter` at runtime. Their VALUE exports live behind the
|
|
93
|
+
// `@titan-design/react-ui/bodymap` subpath to keep this barrel body-highlighter-free.
|
|
94
|
+
// Type-only re-exports stay here (erased at build, so no runtime pull).
|
|
95
|
+
export type { BodyMapProps, BodyMapData } from './BodyMap'
|
|
96
|
+
export type {
|
|
97
|
+
BodyMapDetailPanelProps,
|
|
98
|
+
ContributingExercise,
|
|
99
|
+
UpcomingExercise,
|
|
100
100
|
} from './BodyMapDetailPanel'
|
|
101
|
+
export type {
|
|
102
|
+
TrainingStatusPageProps,
|
|
103
|
+
TrainingStatusMuscle,
|
|
104
|
+
TrainingStatusSummary,
|
|
105
|
+
} from './TrainingStatusPage'
|
|
106
|
+
export {
|
|
107
|
+
ProgramPlanningPage,
|
|
108
|
+
deriveNavLevel,
|
|
109
|
+
toProgressBarMesos,
|
|
110
|
+
findMeso,
|
|
111
|
+
findWeek,
|
|
112
|
+
findWorkout,
|
|
113
|
+
buildBreadcrumbs,
|
|
114
|
+
type ProgramPlanningPageProps,
|
|
115
|
+
type PlanMeso,
|
|
116
|
+
type PlanWeek,
|
|
117
|
+
type PlanWorkout,
|
|
118
|
+
type ProgramNavLevel,
|
|
119
|
+
type ProgramSelection,
|
|
120
|
+
type ProgramBreadcrumb,
|
|
121
|
+
} from './ProgramPlanningPage'
|
|
101
122
|
export {
|
|
123
|
+
ExerciseDetailPage,
|
|
124
|
+
deriveExerciseStats,
|
|
125
|
+
toExerciseCardProps,
|
|
126
|
+
summarizeVbt,
|
|
127
|
+
EXERCISE_DETAIL_TABS,
|
|
128
|
+
type ExerciseDetailPageProps,
|
|
129
|
+
type ExerciseDetailTab,
|
|
130
|
+
type ExerciseDetailHeader,
|
|
131
|
+
type ExerciseDetailEntry,
|
|
132
|
+
type ExerciseTrend,
|
|
133
|
+
type ExerciseVbtSet,
|
|
134
|
+
type ExerciseVbt,
|
|
135
|
+
type ExerciseDetailStats,
|
|
136
|
+
type VbtSummary,
|
|
137
|
+
} from './ExerciseDetailPage'
|
|
138
|
+
// muscleTaxonomy imports `react-native-body-highlighter` at runtime. Its value
|
|
139
|
+
// exports (incl. the MuscleGroup / SimpleMuscleGroup enums) live behind the
|
|
140
|
+
// `@titan-design/react-ui/bodymap` subpath. Only type-only re-exports stay here.
|
|
141
|
+
export type {
|
|
102
142
|
MuscleGroup,
|
|
103
143
|
SimpleMuscleGroup,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
MUSCLE_TO_SVG_SLUGS,
|
|
107
|
-
MUSCLE_TO_CATEGORY,
|
|
108
|
-
MUSCLE_DISPLAY_NAMES,
|
|
109
|
-
SIMPLE_DISPLAY_NAMES,
|
|
110
|
-
DEFAULT_VOLUME_LANDMARKS,
|
|
111
|
-
VOLUME_STATUS_LABELS,
|
|
112
|
-
getHeatmapColor,
|
|
113
|
-
type MovementCategory,
|
|
114
|
-
type VolumeLandmarks,
|
|
144
|
+
MovementCategory,
|
|
145
|
+
VolumeLandmarks,
|
|
115
146
|
} from './muscleTaxonomy'
|
|
147
|
+
export {
|
|
148
|
+
ActiveWorkoutPage,
|
|
149
|
+
countCompletedSets,
|
|
150
|
+
deriveWorkoutProgress,
|
|
151
|
+
findActiveExercise,
|
|
152
|
+
statusToCardState,
|
|
153
|
+
toActiveCardProps,
|
|
154
|
+
groupExercises,
|
|
155
|
+
type ActiveWorkoutPageProps,
|
|
156
|
+
type ActiveWorkoutExercise,
|
|
157
|
+
type ActiveWorkoutSuperset,
|
|
158
|
+
type ActiveWorkoutInput,
|
|
159
|
+
type ActiveWorkoutRest,
|
|
160
|
+
type ActiveExerciseStatus,
|
|
161
|
+
type WorkoutProgress,
|
|
162
|
+
type WorkoutGroup,
|
|
163
|
+
} from './ActiveWorkoutPage'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Web test stub for `nativewind`.
|
|
2
|
+
//
|
|
3
|
+
// The real `nativewind` pulls react-native-css-interop → RN's Flow-typed
|
|
4
|
+
// sources, which the jsdom/react-native-web test environment can't parse
|
|
5
|
+
// ("Unexpected token 'typeof'"). Only `ThemeProvider` imports nativewind (for
|
|
6
|
+
// `vars()`), and only for its NATIVE behavior — on web the color tokens resolve
|
|
7
|
+
// from `global.css`, so an identity `vars()` is a faithful web stand-in.
|
|
8
|
+
// Aliased in `vitest.config.ts`; the real package is used at build/native.
|
|
9
|
+
|
|
10
|
+
/** Identity stand-in for nativewind's `vars()` — returns the token map as-is. */
|
|
11
|
+
export function vars(values: Record<string, string>): Record<string, string> {
|
|
12
|
+
return values
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
4
|
+
import { ThemeProvider } from './ThemeProvider'
|
|
5
|
+
|
|
6
|
+
describe('ThemeProvider', () => {
|
|
7
|
+
it('renders its children', () => {
|
|
8
|
+
render(
|
|
9
|
+
<ThemeProvider>
|
|
10
|
+
<Text>inside</Text>
|
|
11
|
+
</ThemeProvider>,
|
|
12
|
+
)
|
|
13
|
+
expect(screen.getByText('inside')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('accepts a light mode without error', () => {
|
|
17
|
+
render(
|
|
18
|
+
<ThemeProvider mode="light">
|
|
19
|
+
<Text>light</Text>
|
|
20
|
+
</ThemeProvider>,
|
|
21
|
+
)
|
|
22
|
+
expect(screen.getByText('light')).toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Cross-platform theme provider (TD native-theming).
|
|
2
|
+
//
|
|
3
|
+
// Registers titan's semantic color tokens as runtime CSS variables via
|
|
4
|
+
// nativewind's `vars()` so that className color tokens (`text-text-secondary`,
|
|
5
|
+
// `bg-surface-raised`, `border-border`, …) resolve on NATIVE React Native — the
|
|
6
|
+
// native equivalent of the `:root {}` block `global.css` provides on web.
|
|
7
|
+
//
|
|
8
|
+
// Why this exists: titan's Workout organisms reference semantic colors as CSS
|
|
9
|
+
// variables. On web (the dashboard, react-native-web) those come from
|
|
10
|
+
// `global.css`. On native (the mobile app) nothing defined them, so any titan
|
|
11
|
+
// component using a `--color-*` token rendered with an unresolved (black) color.
|
|
12
|
+
// Wrap the mobile app root in `<ThemeProvider>` and the tokens resolve on-device.
|
|
13
|
+
//
|
|
14
|
+
// The var maps are the SAME canonical `darkThemeCSSVars` / `lightThemeCSSVars`
|
|
15
|
+
// that generate `global.css`, so native and web resolve to identical values.
|
|
16
|
+
import { vars } from 'nativewind'
|
|
17
|
+
import { View, type ViewProps } from 'react-native'
|
|
18
|
+
import { darkThemeCSSVars, lightThemeCSSVars } from './config'
|
|
19
|
+
|
|
20
|
+
export type ThemeProviderMode = 'dark' | 'light'
|
|
21
|
+
|
|
22
|
+
// Precomputed once — `vars()` is pure, so the same style object is reused across
|
|
23
|
+
// renders. Native == web because these are the maps `tokens-css` emits.
|
|
24
|
+
const MODE_VARS = {
|
|
25
|
+
dark: vars(darkThemeCSSVars),
|
|
26
|
+
light: vars(lightThemeCSSVars),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ThemeProviderProps extends ViewProps {
|
|
30
|
+
/** Theme mode to register. Mobile is dark-only today; defaults to `dark`. */
|
|
31
|
+
mode?: ThemeProviderMode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap the app root so descendant titan components' `--color-*` className tokens
|
|
36
|
+
* resolve on native. Fills its parent (`flex: 1`) by default; pass `style` to
|
|
37
|
+
* override. On web it is harmless (the dashboard already loads `global.css`).
|
|
38
|
+
*/
|
|
39
|
+
export function ThemeProvider({
|
|
40
|
+
mode = 'dark',
|
|
41
|
+
style,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
}: ThemeProviderProps): React.JSX.Element {
|
|
45
|
+
return (
|
|
46
|
+
<View style={[MODE_VARS[mode], { flex: 1 }, style]} {...props}>
|
|
47
|
+
{children}
|
|
48
|
+
</View>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { darkThemeCSSVars, lightThemeCSSVars } from './config'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drift guard for the token codegen source (TD-05.08).
|
|
9
|
+
*
|
|
10
|
+
* `config.ts` (darkThemeCSSVars / lightThemeCSSVars) is the source that
|
|
11
|
+
* `tokens-css.ts` renders into `dist/tokens.css`. It must stay a complete,
|
|
12
|
+
* value-accurate mirror of the `:root` / `.light` custom properties in
|
|
13
|
+
* `global.css`; otherwise the generated stylesheet silently drifts from the
|
|
14
|
+
* design system (the exact gap this ticket closed). This test fails if
|
|
15
|
+
* global.css gains a `:root` var that config.ts does not cover, or if any
|
|
16
|
+
* shared var's value diverges.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const themeDir = path.dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
const css = readFileSync(path.join(themeDir, 'global.css'), 'utf8')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* global.css :root vars intentionally excluded from the codegen source.
|
|
24
|
+
* Empty today — every :root var is mirrored. Add a var here (with a reason)
|
|
25
|
+
* only if it is genuinely prototype-irrelevant, rather than silently skipping.
|
|
26
|
+
*/
|
|
27
|
+
const CODEGEN_ALLOWLIST = new Set<string>([])
|
|
28
|
+
|
|
29
|
+
function parseVarBlock(header: RegExp): Map<string, string> {
|
|
30
|
+
const match = css.match(header)
|
|
31
|
+
if (!match) throw new Error(`could not locate CSS block for ${header}`)
|
|
32
|
+
const vars = new Map<string, string>()
|
|
33
|
+
for (const line of match[1].split('\n')) {
|
|
34
|
+
const declaration = line.match(/^\s*(--[a-z0-9-]+):\s*(.+?);\s*$/i)
|
|
35
|
+
if (declaration) vars.set(declaration[1], declaration[2].trim())
|
|
36
|
+
}
|
|
37
|
+
return vars
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const globalDark = parseVarBlock(/:root\s*\{([\s\S]*?)\n\s*\}/)
|
|
41
|
+
const globalLight = parseVarBlock(/\.light,\s*\n?\s*:root\.light\s*\{([\s\S]*?)\n\s*\}/)
|
|
42
|
+
|
|
43
|
+
const cases = [
|
|
44
|
+
['dark', globalDark, darkThemeCSSVars as Record<string, string>],
|
|
45
|
+
['light', globalLight, lightThemeCSSVars as Record<string, string>],
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
describe('token codegen completeness (config.ts vs global.css)', () => {
|
|
49
|
+
it('parses both global.css theme blocks', () => {
|
|
50
|
+
expect(globalDark.size).toBeGreaterThan(90)
|
|
51
|
+
expect(globalLight.size).toBe(globalDark.size)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
for (const [mode, globalVars, cfg] of cases) {
|
|
55
|
+
it(`${mode}: config covers every global.css :root var with the exact value`, () => {
|
|
56
|
+
const missing: string[] = []
|
|
57
|
+
const mismatched: string[] = []
|
|
58
|
+
for (const [name, value] of globalVars) {
|
|
59
|
+
if (CODEGEN_ALLOWLIST.has(name)) continue
|
|
60
|
+
if (!(name in cfg)) {
|
|
61
|
+
missing.push(name)
|
|
62
|
+
} else if (cfg[name] !== value) {
|
|
63
|
+
mismatched.push(`${name}: global='${value}' config='${cfg[name]}'`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
expect(missing, 'global.css vars missing from config.ts codegen source').toEqual([])
|
|
67
|
+
expect(mismatched, 'value drift between config.ts and global.css').toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it(`${mode}: config emits no var absent from global.css`, () => {
|
|
71
|
+
const extra = Object.keys(cfg).filter(
|
|
72
|
+
(name) => !globalVars.has(name) && !CODEGEN_ALLOWLIST.has(name),
|
|
73
|
+
)
|
|
74
|
+
expect(extra, 'config.ts vars not present in global.css :root').toEqual([])
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
})
|
package/src/theme/config.ts
CHANGED
|
@@ -7,6 +7,35 @@
|
|
|
7
7
|
|
|
8
8
|
import { semanticColorsLight, semanticColorsDark, type ThemeMode } from './tokens/semantic'
|
|
9
9
|
|
|
10
|
+
// Theme-independent custom properties: identical in light and dark in
|
|
11
|
+
// global.css (RGB glow decompositions, font families, animation tokens). Kept
|
|
12
|
+
// as literals since they are not part of the semantic COLOR maps, and spread
|
|
13
|
+
// into both theme maps so codegen mirrors global.css exactly.
|
|
14
|
+
const themeIndependentCSSVars = {
|
|
15
|
+
// RGB decomposed values for glow shadows
|
|
16
|
+
'--color-brand-primary-rgb': '255, 121, 0',
|
|
17
|
+
'--color-brand-secondary-rgb': '64, 109, 135',
|
|
18
|
+
'--color-status-success-rgb': '20, 184, 166',
|
|
19
|
+
'--color-status-error-rgb': '209, 67, 67',
|
|
20
|
+
'--color-status-warning-rgb': '255, 176, 32',
|
|
21
|
+
'--color-status-info-rgb': '33, 150, 243',
|
|
22
|
+
'--color-background-base-rgb': '16, 16, 16',
|
|
23
|
+
|
|
24
|
+
// Font families
|
|
25
|
+
'--font-family-sans': "'Inter'",
|
|
26
|
+
'--font-family-body': "'Nunito Sans'",
|
|
27
|
+
'--font-family-heading': "'Space Grotesk'",
|
|
28
|
+
'--font-family-mono': "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace",
|
|
29
|
+
|
|
30
|
+
// Animation tokens
|
|
31
|
+
'--ease-out': 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
32
|
+
'--ease-in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
33
|
+
'--ease-spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
34
|
+
'--duration-fast': '150ms',
|
|
35
|
+
'--duration-normal': '250ms',
|
|
36
|
+
'--duration-slow': '400ms',
|
|
37
|
+
} as const
|
|
38
|
+
|
|
10
39
|
// CSS custom properties for light mode
|
|
11
40
|
export const lightThemeCSSVars = {
|
|
12
41
|
'--color-brand-primary': semanticColorsLight['brand-primary'],
|
|
@@ -57,6 +86,67 @@ export const lightThemeCSSVars = {
|
|
|
57
86
|
'--color-interactive-disabled': semanticColorsLight['interactive-disabled'],
|
|
58
87
|
|
|
59
88
|
'--color-divider': semanticColorsLight['divider'],
|
|
89
|
+
|
|
90
|
+
'--color-brand-primary-hover': semanticColorsLight['brand-primary-hover'],
|
|
91
|
+
'--color-brand-primary-active': semanticColorsLight['brand-primary-active'],
|
|
92
|
+
'--color-brand-secondary-hover': semanticColorsLight['brand-secondary-hover'],
|
|
93
|
+
'--color-brand-secondary-active': semanticColorsLight['brand-secondary-active'],
|
|
94
|
+
|
|
95
|
+
'--color-status-success-light': semanticColorsLight['status-success-light'],
|
|
96
|
+
'--color-status-success-dark': semanticColorsLight['status-success-dark'],
|
|
97
|
+
'--color-status-error-light': semanticColorsLight['status-error-light'],
|
|
98
|
+
'--color-status-error-dark': semanticColorsLight['status-error-dark'],
|
|
99
|
+
'--color-status-warning-light': semanticColorsLight['status-warning-light'],
|
|
100
|
+
'--color-status-warning-dark': semanticColorsLight['status-warning-dark'],
|
|
101
|
+
'--color-status-info-light': semanticColorsLight['status-info-light'],
|
|
102
|
+
'--color-status-info-dark': semanticColorsLight['status-info-dark'],
|
|
103
|
+
|
|
104
|
+
'--color-on-status-success': semanticColorsLight['on-status-success'],
|
|
105
|
+
'--color-on-status-error': semanticColorsLight['on-status-error'],
|
|
106
|
+
'--color-on-status-warning': semanticColorsLight['on-status-warning'],
|
|
107
|
+
'--color-on-status-info': semanticColorsLight['on-status-info'],
|
|
108
|
+
|
|
109
|
+
'--color-result-improve': semanticColorsLight['result-improve'],
|
|
110
|
+
'--color-result-improve-light': semanticColorsLight['result-improve-light'],
|
|
111
|
+
'--color-result-improve-dark': semanticColorsLight['result-improve-dark'],
|
|
112
|
+
'--color-result-degrade': semanticColorsLight['result-degrade'],
|
|
113
|
+
'--color-result-degrade-light': semanticColorsLight['result-degrade-light'],
|
|
114
|
+
'--color-result-degrade-dark': semanticColorsLight['result-degrade-dark'],
|
|
115
|
+
'--color-result-inconclusive': semanticColorsLight['result-inconclusive'],
|
|
116
|
+
'--color-result-inconclusive-light': semanticColorsLight['result-inconclusive-light'],
|
|
117
|
+
'--color-result-neutral': semanticColorsLight['result-neutral'],
|
|
118
|
+
|
|
119
|
+
'--color-on-result-improve': semanticColorsLight['on-result-improve'],
|
|
120
|
+
'--color-on-result-degrade': semanticColorsLight['on-result-degrade'],
|
|
121
|
+
'--color-on-result-inconclusive': semanticColorsLight['on-result-inconclusive'],
|
|
122
|
+
|
|
123
|
+
'--color-data-1': semanticColorsLight['data-1'],
|
|
124
|
+
'--color-data-2': semanticColorsLight['data-2'],
|
|
125
|
+
'--color-data-3': semanticColorsLight['data-3'],
|
|
126
|
+
'--color-data-4': semanticColorsLight['data-4'],
|
|
127
|
+
'--color-data-5': semanticColorsLight['data-5'],
|
|
128
|
+
'--color-data-6': semanticColorsLight['data-6'],
|
|
129
|
+
'--color-data-7': semanticColorsLight['data-7'],
|
|
130
|
+
'--color-data-8': semanticColorsLight['data-8'],
|
|
131
|
+
'--color-data-9': semanticColorsLight['data-9'],
|
|
132
|
+
'--color-data-10': semanticColorsLight['data-10'],
|
|
133
|
+
|
|
134
|
+
'--color-text-link-hover': semanticColorsLight['text-link-hover'],
|
|
135
|
+
|
|
136
|
+
'--color-surface-overlay': semanticColorsLight['surface-overlay'],
|
|
137
|
+
'--color-surface-input': semanticColorsLight['surface-input'],
|
|
138
|
+
|
|
139
|
+
'--color-border-input': semanticColorsLight['border-input'],
|
|
140
|
+
'--color-border-input-hover': semanticColorsLight['border-input-hover'],
|
|
141
|
+
'--color-border-input-focus': semanticColorsLight['border-input-focus'],
|
|
142
|
+
'--color-border-input-error': semanticColorsLight['border-input-error'],
|
|
143
|
+
|
|
144
|
+
'--color-interactive-disabled-text': semanticColorsLight['interactive-disabled-text'],
|
|
145
|
+
|
|
146
|
+
'--color-avatar-background': semanticColorsLight['avatar-background'],
|
|
147
|
+
'--color-avatar-text': semanticColorsLight['avatar-text'],
|
|
148
|
+
|
|
149
|
+
...themeIndependentCSSVars,
|
|
60
150
|
} as const
|
|
61
151
|
|
|
62
152
|
// CSS custom properties for dark mode (default)
|
|
@@ -109,6 +199,67 @@ export const darkThemeCSSVars = {
|
|
|
109
199
|
'--color-interactive-disabled': semanticColorsDark['interactive-disabled'],
|
|
110
200
|
|
|
111
201
|
'--color-divider': semanticColorsDark['divider'],
|
|
202
|
+
|
|
203
|
+
'--color-brand-primary-hover': semanticColorsDark['brand-primary-hover'],
|
|
204
|
+
'--color-brand-primary-active': semanticColorsDark['brand-primary-active'],
|
|
205
|
+
'--color-brand-secondary-hover': semanticColorsDark['brand-secondary-hover'],
|
|
206
|
+
'--color-brand-secondary-active': semanticColorsDark['brand-secondary-active'],
|
|
207
|
+
|
|
208
|
+
'--color-status-success-light': semanticColorsDark['status-success-light'],
|
|
209
|
+
'--color-status-success-dark': semanticColorsDark['status-success-dark'],
|
|
210
|
+
'--color-status-error-light': semanticColorsDark['status-error-light'],
|
|
211
|
+
'--color-status-error-dark': semanticColorsDark['status-error-dark'],
|
|
212
|
+
'--color-status-warning-light': semanticColorsDark['status-warning-light'],
|
|
213
|
+
'--color-status-warning-dark': semanticColorsDark['status-warning-dark'],
|
|
214
|
+
'--color-status-info-light': semanticColorsDark['status-info-light'],
|
|
215
|
+
'--color-status-info-dark': semanticColorsDark['status-info-dark'],
|
|
216
|
+
|
|
217
|
+
'--color-on-status-success': semanticColorsDark['on-status-success'],
|
|
218
|
+
'--color-on-status-error': semanticColorsDark['on-status-error'],
|
|
219
|
+
'--color-on-status-warning': semanticColorsDark['on-status-warning'],
|
|
220
|
+
'--color-on-status-info': semanticColorsDark['on-status-info'],
|
|
221
|
+
|
|
222
|
+
'--color-result-improve': semanticColorsDark['result-improve'],
|
|
223
|
+
'--color-result-improve-light': semanticColorsDark['result-improve-light'],
|
|
224
|
+
'--color-result-improve-dark': semanticColorsDark['result-improve-dark'],
|
|
225
|
+
'--color-result-degrade': semanticColorsDark['result-degrade'],
|
|
226
|
+
'--color-result-degrade-light': semanticColorsDark['result-degrade-light'],
|
|
227
|
+
'--color-result-degrade-dark': semanticColorsDark['result-degrade-dark'],
|
|
228
|
+
'--color-result-inconclusive': semanticColorsDark['result-inconclusive'],
|
|
229
|
+
'--color-result-inconclusive-light': semanticColorsDark['result-inconclusive-light'],
|
|
230
|
+
'--color-result-neutral': semanticColorsDark['result-neutral'],
|
|
231
|
+
|
|
232
|
+
'--color-on-result-improve': semanticColorsDark['on-result-improve'],
|
|
233
|
+
'--color-on-result-degrade': semanticColorsDark['on-result-degrade'],
|
|
234
|
+
'--color-on-result-inconclusive': semanticColorsDark['on-result-inconclusive'],
|
|
235
|
+
|
|
236
|
+
'--color-data-1': semanticColorsDark['data-1'],
|
|
237
|
+
'--color-data-2': semanticColorsDark['data-2'],
|
|
238
|
+
'--color-data-3': semanticColorsDark['data-3'],
|
|
239
|
+
'--color-data-4': semanticColorsDark['data-4'],
|
|
240
|
+
'--color-data-5': semanticColorsDark['data-5'],
|
|
241
|
+
'--color-data-6': semanticColorsDark['data-6'],
|
|
242
|
+
'--color-data-7': semanticColorsDark['data-7'],
|
|
243
|
+
'--color-data-8': semanticColorsDark['data-8'],
|
|
244
|
+
'--color-data-9': semanticColorsDark['data-9'],
|
|
245
|
+
'--color-data-10': semanticColorsDark['data-10'],
|
|
246
|
+
|
|
247
|
+
'--color-text-link-hover': semanticColorsDark['text-link-hover'],
|
|
248
|
+
|
|
249
|
+
'--color-surface-overlay': semanticColorsDark['surface-overlay'],
|
|
250
|
+
'--color-surface-input': semanticColorsDark['surface-input'],
|
|
251
|
+
|
|
252
|
+
'--color-border-input': semanticColorsDark['border-input'],
|
|
253
|
+
'--color-border-input-hover': semanticColorsDark['border-input-hover'],
|
|
254
|
+
'--color-border-input-focus': semanticColorsDark['border-input-focus'],
|
|
255
|
+
'--color-border-input-error': semanticColorsDark['border-input-error'],
|
|
256
|
+
|
|
257
|
+
'--color-interactive-disabled-text': semanticColorsDark['interactive-disabled-text'],
|
|
258
|
+
|
|
259
|
+
'--color-avatar-background': semanticColorsDark['avatar-background'],
|
|
260
|
+
'--color-avatar-text': semanticColorsDark['avatar-text'],
|
|
261
|
+
|
|
262
|
+
...themeIndependentCSSVars,
|
|
112
263
|
} as const
|
|
113
264
|
|
|
114
265
|
// Helper to get CSS vars for a theme mode
|
package/src/theme/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export * from './config'
|
|
|
4
4
|
export * from './shadows'
|
|
5
5
|
export * from './elevation'
|
|
6
6
|
export * from './manifest'
|
|
7
|
+
export { ThemeProvider, type ThemeProviderProps, type ThemeProviderMode } from './ThemeProvider'
|
|
8
|
+
export { resolveColor } from './resolve-color'
|
|
7
9
|
|
|
8
10
|
// Re-export commonly used items
|
|
9
11
|
export { getSemanticColors, type ThemeMode } from './tokens/semantic'
|