@titan-design/react-ui 0.4.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 (67) 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.mjs +1911 -6
  6. package/dist/bodymap.mjs.map +1 -1
  7. package/dist/index.d.mts +31 -738
  8. package/dist/index.d.ts +31 -738
  9. package/dist/index.js +894 -1692
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +10287 -8203
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/pages-D7Jlssvp.d.ts +791 -0
  14. package/dist/pages-MO0JCySL.d.mts +791 -0
  15. package/dist/pages.d.mts +4 -0
  16. package/dist/pages.d.ts +4 -0
  17. package/dist/pages.js +6646 -0
  18. package/dist/pages.js.map +1 -0
  19. package/dist/pages.mjs +6626 -0
  20. package/dist/pages.mjs.map +1 -0
  21. package/dist/theme/index.d.mts +12 -12
  22. package/dist/theme/index.d.ts +12 -12
  23. package/dist/theme/index.js +30 -30
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +1880 -2
  26. package/dist/theme/index.mjs.map +1 -1
  27. package/dist/theme/tokens-css.mjs +573 -1
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +9 -7
  30. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  31. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  32. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  33. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  34. package/src/components/custom/Workout/ExerciseDetailPage.tsx +17 -4
  35. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  36. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  37. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  38. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  39. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  40. package/src/components/custom/Workout/README.md +50 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  42. package/src/components/custom/Workout/RestTimer.tsx +48 -43
  43. package/src/components/custom/Workout/SetRow.tsx +10 -1
  44. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  45. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  46. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  47. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  48. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  49. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  50. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  51. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  52. package/src/components/custom/Workout/icons.tsx +68 -0
  53. package/src/components/custom/Workout/index.ts +38 -47
  54. package/src/index.ts +3 -2
  55. package/src/pages.ts +61 -0
  56. package/src/test/stories-smoke.test.tsx +50 -0
  57. package/src/theme/barrel.ts +53 -0
  58. package/src/theme/index.ts +8 -43
  59. package/src/theme/native-theming-guard.test.ts +117 -0
  60. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  61. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  62. package/dist/chunk-2SISKTWM.mjs +0 -1027
  63. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  64. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  65. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  66. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  67. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -1,11 +1,54 @@
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, Pressable, Animated, Easing, type ViewProps } from 'react-native'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps, type ViewStyle } from 'react-native'
4
4
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
5
  import { formatVelocity } from '../../../utils/workout-format'
6
6
 
7
+ /**
8
+ * Structural velocity-zone band accepted from an upstream analytics source
9
+ * (e.g. workout-analytics' `VelocityZones.bands`).
10
+ *
11
+ * Deliberately a plain structural shape — titan never imports the analytics
12
+ * package (same policy as TempoBar's presentational phase key). Any object with
13
+ * this shape can be passed, so `zones={waVelocityZones.bands}` works directly.
14
+ * Bands are ordered slow → fast, contiguous, and cover `[0, ∞)` with the top
15
+ * band's `max === null`. Bands carry NO color — color is a UI concern resolved
16
+ * here via {@link zoneIdToScaleToken}.
17
+ */
18
+ export interface VelocityZoneBandProp {
19
+ /** Stable zone identity (e.g. WA's `VelocityZoneId`). Drives color mapping. */
20
+ id: string
21
+ /** Human-readable label shown in the summary row. */
22
+ label: string
23
+ /** Inclusive lower bound (m/s mean concentric velocity). */
24
+ min: number
25
+ /** Exclusive upper bound (m/s); `null` marks the open top band. */
26
+ max: number | null
27
+ }
28
+
7
29
  export interface VelocityStripProps extends ViewProps {
30
+ /**
31
+ * Per-rep MEAN concentric velocity (m/s), one entry per rep (brain WA-D02).
32
+ * Drives both bar height and — in the default path — zone color. Callers must
33
+ * feed mean (not peak) concentric velocity so bar color, height, and the
34
+ * summary label all describe one metric.
35
+ */
8
36
  velocities: number[]
37
+ /**
38
+ * Optional velocity-zone bands (shape-compatible with WA's
39
+ * `VelocityZones.bands`). When provided, bar color and the summary zone label
40
+ * come from these bands via {@link zoneIdToScaleToken}. When omitted, the
41
+ * built-in default scale (≥1.0 / ≥0.75 / ≥0.5) is used — identical to prior
42
+ * releases. Colors NEVER come from the bands themselves.
43
+ */
44
+ zones?: readonly VelocityZoneBandProp[]
45
+ /**
46
+ * Live mode: index of the most-recently-completed rep. That bar animates in
47
+ * with a "pop"; if it is also the current set peak (a new best), it "bounces"
48
+ * instead. Only the latest bar animates. Honors `prefers-reduced-motion`.
49
+ * Full variant only.
50
+ */
51
+ liveRepIndex?: number
9
52
  expanded?: boolean
10
53
  onToggle?: () => void
11
54
  onRepPress?: (index: number, velocity: number) => void
@@ -14,9 +57,36 @@ export interface VelocityStripProps extends ViewProps {
14
57
  className?: string
15
58
  }
16
59
 
17
- // Canonical scale shared with SetRow's RPE color (see theme/workout-tokens.ts).
60
+ // Canonical 4-color performance scale shared with SetRow's RPE color
61
+ // (see theme/workout-tokens.ts). green = fastest, red = slowest/grinding.
18
62
  const VEL_COLORS = WORKOUT_TOKENS.scale
19
63
 
64
+ /**
65
+ * Map a velocity-zone id (WA's 5-band taxonomy) onto the canonical 4-color
66
+ * scale. The scale has 4 hues but the taxonomy has 5 bands, so the two slowest
67
+ * bands — `maximalStrength` and `grinding` — intentionally share `red`: both
68
+ * read as "heavy / effortful" and there is no distinct 5th data-viz hue. The
69
+ * top three bands align 1:1 with the legacy default scale so an exercise moving
70
+ * from default to profile-derived zones never shifts its fast-end colors.
71
+ *
72
+ * Legacy default parity: speed=green, power=yellow, strengthSpeed=orange, and
73
+ * the old sub-0.5 "Strength" band (now split into maximalStrength + grinding)
74
+ * stays red.
75
+ *
76
+ * Unknown ids fall back to `green` (matching the historical default-path
77
+ * fallback), so a forward-compatible band id never renders an empty bar.
78
+ */
79
+ const zoneIdToScaleToken: Record<string, keyof typeof VEL_COLORS> = {
80
+ speed: 'green',
81
+ power: 'yellow',
82
+ strengthSpeed: 'orange',
83
+ maximalStrength: 'red',
84
+ grinding: 'red',
85
+ }
86
+
87
+ // --- Default (no-zones) scale ------------------------------------------------
88
+ // Retained as named exports for back-compat; they define the default path.
89
+
20
90
  export function getVelocityZoneColor(velocity: number): string {
21
91
  if (velocity >= 1.0) return 'vel-green'
22
92
  if (velocity >= 0.75) return 'vel-yellow'
@@ -31,35 +101,47 @@ export function getVelocityZoneName(velocity: number): string {
31
101
  return 'Strength'
32
102
  }
33
103
 
104
+ const zoneHexMap: Record<string, string> = {
105
+ 'vel-green': VEL_COLORS.green,
106
+ 'vel-yellow': VEL_COLORS.yellow,
107
+ 'vel-orange': VEL_COLORS.orange,
108
+ 'vel-red': VEL_COLORS.red,
109
+ }
110
+
111
+ /**
112
+ * Velocity loss for a set, as a whole percentage. Uses the running-best rep as
113
+ * the reference (matching WA-02.05 / brain WA-D01): `(vBest − vLast) / vBest`,
114
+ * clamped to ≥ 0 so a set that ends on its best rep reports 0 loss.
115
+ */
34
116
  export function calculateVelocityLoss(velocities: number[]): number {
35
117
  if (velocities.length < 2) return 0
36
- const first = velocities[0]
118
+ const best = Math.max(...velocities)
119
+ if (best <= 0) return 0
37
120
  const last = velocities[velocities.length - 1]
38
- if (first === 0) return 0
39
- return Math.round(((first - last) / first) * 100)
121
+ return Math.max(0, Math.round(((best - last) / best) * 100))
40
122
  }
41
123
 
124
+ /** Arithmetic mean of the per-rep mean-concentric velocities. */
42
125
  export function calculateMeanVelocity(velocities: number[]): number {
43
126
  if (velocities.length === 0) return 0
44
127
  const sum = velocities.reduce((acc, v) => acc + v, 0)
45
128
  return sum / velocities.length
46
129
  }
47
130
 
48
- function getBarColor(zone: string): string {
49
- switch (zone) {
50
- case 'vel-green': return VEL_COLORS.green
51
- case 'vel-yellow': return VEL_COLORS.yellow
52
- case 'vel-orange': return VEL_COLORS.orange
53
- case 'vel-red': return VEL_COLORS.red
54
- default: return VEL_COLORS.green
131
+ /** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
132
+ function classifyBand(
133
+ velocity: number,
134
+ bands: readonly VelocityZoneBandProp[],
135
+ ): VelocityZoneBandProp | undefined {
136
+ for (const band of bands) {
137
+ if (band.max === null || velocity < band.max) return band
55
138
  }
139
+ return bands[bands.length - 1]
56
140
  }
57
141
 
58
- const zoneHexMap: Record<string, string> = {
59
- 'vel-green': VEL_COLORS.green,
60
- 'vel-yellow': VEL_COLORS.yellow,
61
- 'vel-orange': VEL_COLORS.orange,
62
- 'vel-red': VEL_COLORS.red,
142
+ function bandColor(band: VelocityZoneBandProp): string {
143
+ const token = zoneIdToScaleToken[band.id]
144
+ return token ? VEL_COLORS[token] : VEL_COLORS.green
63
145
  }
64
146
 
65
147
  function getLossStyle(loss: number): Record<string, string> | null {
@@ -68,11 +150,33 @@ function getLossStyle(loss: number): Record<string, string> | null {
68
150
  return null
69
151
  }
70
152
 
153
+ function getReducedMotionPreference(): boolean {
154
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
155
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
156
+ }
157
+
158
+ /** Track the OS "reduce motion" preference; falls back to `false` (jsdom/SSR). */
159
+ function usePrefersReducedMotion(): boolean {
160
+ const [reduced, setReduced] = useState(getReducedMotionPreference)
161
+ useEffect(() => {
162
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
163
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
164
+ const handler = () => setReduced(mq.matches)
165
+ handler()
166
+ mq.addEventListener?.('change', handler)
167
+ return () => mq.removeEventListener?.('change', handler)
168
+ }, [])
169
+ return reduced
170
+ }
171
+
71
172
  const ANIMATION_DURATION = 400
72
173
  const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
174
+ const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
73
175
 
74
176
  export function VelocityStrip({
75
177
  velocities,
178
+ zones,
179
+ liveRepIndex,
76
180
  expanded = false,
77
181
  onToggle,
78
182
  onRepPress,
@@ -84,11 +188,55 @@ export function VelocityStrip({
84
188
  const maxVelocity = Math.max(...velocities, 0)
85
189
  const meanVelocity = calculateMeanVelocity(velocities)
86
190
  const loss = calculateVelocityLoss(velocities)
87
- const meanZone = getVelocityZoneName(meanVelocity)
88
191
 
192
+ const hasZones = zones != null && zones.length > 0
193
+ const barColorFor = (v: number): string =>
194
+ hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
195
+ const meanZone = hasZones
196
+ ? (classifyBand(meanVelocity, zones)?.label ?? '')
197
+ : getVelocityZoneName(meanVelocity)
198
+
199
+ const prefersReducedMotion = usePrefersReducedMotion()
89
200
  const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
90
201
  const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
91
202
  const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
203
+ const [liveScale] = useState(() => new Animated.Value(1))
204
+
205
+ // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
206
+ const liveVelocity = liveRepIndex != null ? velocities[liveRepIndex] : undefined
207
+ const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
208
+ useEffect(() => {
209
+ if (variant === 'mini' || liveRepIndex == null || liveVelocity == null) return
210
+ if (prefersReducedMotion) {
211
+ liveScale.setValue(1)
212
+ return
213
+ }
214
+ if (isNewPeak) {
215
+ liveScale.setValue(1)
216
+ Animated.sequence([
217
+ Animated.timing(liveScale, {
218
+ toValue: 1.25,
219
+ duration: 150,
220
+ easing: Easing.out(Easing.quad),
221
+ useNativeDriver: true,
222
+ }),
223
+ Animated.spring(liveScale, {
224
+ toValue: 1,
225
+ friction: 3,
226
+ tension: 140,
227
+ useNativeDriver: true,
228
+ }),
229
+ ]).start()
230
+ } else {
231
+ liveScale.setValue(0.8)
232
+ Animated.timing(liveScale, {
233
+ toValue: 1,
234
+ duration: 300,
235
+ easing: POP_EASING,
236
+ useNativeDriver: true,
237
+ }).start()
238
+ }
239
+ }, [variant, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
92
240
 
93
241
  useEffect(() => {
94
242
  if (variant === 'mini') return
@@ -131,7 +279,7 @@ export function VelocityStrip({
131
279
  key={i}
132
280
  style={{
133
281
  flex: 1,
134
- backgroundColor: getBarColor(getVelocityZoneColor(v)),
282
+ backgroundColor: barColorFor(v),
135
283
  borderRadius: 1,
136
284
  minWidth: 4,
137
285
  height: '100%' as unknown as number,
@@ -176,11 +324,44 @@ export function VelocityStrip({
176
324
  style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
177
325
  >
178
326
  {velocities.map((v, i) => {
179
- const zone = getVelocityZoneColor(v)
327
+ const barBackground = barColorFor(v)
180
328
  // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
181
329
  // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
182
330
  const barHeightPct =
183
331
  maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
332
+ const isLive = liveRepIndex === i
333
+ const liveLabelSuffix = isLive
334
+ ? isNewPeak
335
+ ? ', latest rep, new set peak'
336
+ : ', latest rep'
337
+ : ''
338
+
339
+ const barStyle: ViewStyle = expanded
340
+ ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: barBackground }
341
+ : {
342
+ minHeight: '100%' as unknown as number,
343
+ borderTopLeftRadius: 2,
344
+ borderTopRightRadius: 2,
345
+ borderBottomLeftRadius: 0,
346
+ borderBottomRightRadius: 0,
347
+ backgroundColor: barBackground,
348
+ }
349
+
350
+ const barInner = isLive ? (
351
+ <Animated.View
352
+ style={[barStyle, { transform: [{ scale: liveScale }] }]}
353
+ testID={`velocity-bar-${i}`}
354
+ accessibilityRole="image"
355
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
356
+ />
357
+ ) : (
358
+ <View
359
+ style={barStyle}
360
+ testID={`velocity-bar-${i}`}
361
+ accessibilityRole="image"
362
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
363
+ />
364
+ )
184
365
 
185
366
  const bar = (
186
367
  <View
@@ -198,23 +379,7 @@ export function VelocityStrip({
198
379
  </Text>
199
380
  </Animated.View>
200
381
  )}
201
- <View
202
- style={
203
- expanded
204
- ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
205
- : {
206
- minHeight: '100%' as unknown as number,
207
- borderTopLeftRadius: 2,
208
- borderTopRightRadius: 2,
209
- borderBottomLeftRadius: 0,
210
- borderBottomRightRadius: 0,
211
- backgroundColor: zoneHexMap[zone],
212
- }
213
- }
214
- testID={`velocity-bar-${i}`}
215
- accessibilityRole="image"
216
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
217
- />
382
+ {barInner}
218
383
  </View>
219
384
  )
220
385
 
@@ -256,7 +421,7 @@ export function VelocityStrip({
256
421
  fontFamily: 'Inter, sans-serif',
257
422
  }}
258
423
  >
259
- {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
424
+ {meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
260
425
  </Text>
261
426
  <Text
262
427
  className="text-text-secondary"
@@ -37,12 +37,12 @@ describe('WeekRow', () => {
37
37
 
38
38
  it('passes the threshold through to the intensity bar', () => {
39
39
  render(<WeekRow {...baseProps} intensityThreshold={0.8} />)
40
- expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
40
+ expect(screen.getByTestId('intensity-target')).toBeInTheDocument()
41
41
  })
42
42
 
43
43
  it('omits the threshold line when not provided', () => {
44
44
  render(<WeekRow {...baseProps} />)
45
- expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
45
+ expect(screen.queryByTestId('intensity-target')).not.toBeInTheDocument()
46
46
  })
47
47
  })
48
48
 
@@ -75,10 +75,10 @@ describe('WeightBadge', () => {
75
75
  })
76
76
  })
77
77
 
78
- it('uses brand-primary-subtle border color on PR state', () => {
78
+ it('uses a brand-primary border ring on PR state', () => {
79
79
  render(<WeightBadge value={315} isPr />)
80
80
  expect(screen.getByTestId('weight-badge')).toHaveStyle({
81
- borderTopColor: 'rgba(255, 121, 0, 0.12)',
81
+ borderTopColor: 'rgba(255, 121, 0, 0.3)',
82
82
  })
83
83
  })
84
84
  })
@@ -1,6 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { Text } from 'react-native'
3
- import { Dumbbell } from 'lucide-react'
3
+ import { DumbbellIcon } from './icons'
4
4
  import {
5
5
  BaseBadge,
6
6
  baseBadgeSizeConfig,
@@ -61,7 +61,7 @@ export function WeightBadge({
61
61
  className={className}
62
62
  icon={
63
63
  showIcon ? (
64
- <Dumbbell size={config.iconSize} color={iconColor} strokeWidth={2} />
64
+ <DumbbellIcon size={config.iconSize} color={iconColor} strokeWidth={2} />
65
65
  ) : undefined
66
66
  }
67
67
  accessibilityLabel={fullLabel}
@@ -0,0 +1,68 @@
1
+ // Inline SVG icons for the Workout badges — replaces the `lucide-react`
2
+ // dependency (dep hygiene, titan 0.5.0). The paths mirror lucide-react's
3
+ // `Dumbbell` and `Star` glyphs verbatim so rendering is pixel-identical to the
4
+ // previous `<Dumbbell>` / `<Star>` output (same viewBox, stroke, fill, and
5
+ // round line caps). Rendered as raw `<svg>` DOM elements — the same web-SVG
6
+ // mechanism `Progress.tsx` uses, and the same surface `lucide-react` produced
7
+ // (both are web-only SVG; native icon rendering was never wired here).
8
+
9
+ export interface WorkoutIconProps {
10
+ /** Rendered width/height in px (viewBox is fixed 24×24). */
11
+ size?: number
12
+ /** Stroke color. */
13
+ color?: string
14
+ /** Fill color; defaults to `none` (outline only). */
15
+ fill?: string
16
+ /** Stroke width. */
17
+ strokeWidth?: number
18
+ }
19
+
20
+ /** Dumbbell glyph (mirrors lucide-react `Dumbbell`). Outline-only by default. */
21
+ export function DumbbellIcon({
22
+ size = 24,
23
+ color = 'currentColor',
24
+ fill = 'none',
25
+ strokeWidth = 2,
26
+ }: WorkoutIconProps) {
27
+ return (
28
+ <svg
29
+ width={size}
30
+ height={size}
31
+ viewBox="0 0 24 24"
32
+ fill={fill}
33
+ stroke={color}
34
+ strokeWidth={strokeWidth}
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ >
38
+ <path d="M14.4 14.4 9.6 9.6" />
39
+ <path d="M18.657 21.485a2 2 0 1 1-2.829-2.828l-1.767 1.768a2 2 0 1 1-2.829-2.829l6.364-6.364a2 2 0 1 1 2.829 2.829l-1.768 1.767a2 2 0 1 1 2.828 2.829z" />
40
+ <path d="m21.5 21.5-1.4-1.4" />
41
+ <path d="M3.9 3.9 2.5 2.5" />
42
+ <path d="M6.404 12.768a2 2 0 1 1-2.829-2.829l1.768-1.767a2 2 0 1 1-2.828-2.829l2.828-2.828a2 2 0 1 1 2.829 2.828l1.767-1.768a2 2 0 1 1 2.829 2.829z" />
43
+ </svg>
44
+ )
45
+ }
46
+
47
+ /** Star glyph (mirrors lucide-react `Star`). Pass `fill` for a solid star. */
48
+ export function StarIcon({
49
+ size = 24,
50
+ color = 'currentColor',
51
+ fill = 'none',
52
+ strokeWidth = 2,
53
+ }: WorkoutIconProps) {
54
+ return (
55
+ <svg
56
+ width={size}
57
+ height={size}
58
+ viewBox="0 0 24 24"
59
+ fill={fill}
60
+ stroke={color}
61
+ strokeWidth={strokeWidth}
62
+ strokeLinecap="round"
63
+ strokeLinejoin="round"
64
+ >
65
+ <path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
66
+ </svg>
67
+ )
68
+ }
@@ -8,7 +8,12 @@ export { WeightBadge, type WeightBadgeProps, type WeightBadgeSize } from './Weig
8
8
  export { PrBadge, type PrBadgeProps, type PRType } from './PrBadge'
9
9
  export { StatusDot, type StatusDotVariant, type StatusDotProps } from './StatusDot'
10
10
  export { PlaceholderStrip, type PlaceholderStripProps } from './PlaceholderStrip'
11
- export { TempoDisplay, type TempoDisplayProps } from './TempoDisplay'
11
+ export {
12
+ TempoDisplay,
13
+ type TempoDisplayProps,
14
+ type TempoLivePhase,
15
+ type TempoLiveState,
16
+ } from './TempoDisplay'
12
17
  export {
13
18
  TempoBar,
14
19
  type TempoBarProps,
@@ -24,6 +29,7 @@ export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './Wo
24
29
  export {
25
30
  VelocityStrip,
26
31
  type VelocityStripProps,
32
+ type VelocityZoneBandProp,
27
33
  getVelocityZoneColor,
28
34
  getVelocityZoneName,
29
35
  calculateVelocityLoss,
@@ -103,37 +109,29 @@ export type {
103
109
  TrainingStatusMuscle,
104
110
  TrainingStatusSummary,
105
111
  } 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,
112
+ // ActiveWorkoutPage / ExerciseDetailPage / ProgramPlanningPage are page-level
113
+ // organisms demoted to the `@titan-design/react-ui/pages` subpath (titan 0.5.0).
114
+ // Their VALUE exports (components + derivation helpers) live there; only
115
+ // type-only re-exports stay on this barrel (erased at build, no runtime pull).
116
+ export type {
117
+ ProgramPlanningPageProps,
118
+ PlanMeso,
119
+ PlanWeek,
120
+ PlanWorkout,
121
+ ProgramNavLevel,
122
+ ProgramSelection,
123
+ ProgramBreadcrumb,
121
124
  } from './ProgramPlanningPage'
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,
125
+ export type {
126
+ ExerciseDetailPageProps,
127
+ ExerciseDetailTab,
128
+ ExerciseDetailHeader,
129
+ ExerciseDetailEntry,
130
+ ExerciseTrend,
131
+ ExerciseVbtSet,
132
+ ExerciseVbt,
133
+ ExerciseDetailStats,
134
+ VbtSummary,
137
135
  } from './ExerciseDetailPage'
138
136
  // muscleTaxonomy imports `react-native-body-highlighter` at runtime. Its value
139
137
  // exports (incl. the MuscleGroup / SimpleMuscleGroup enums) live behind the
@@ -144,20 +142,13 @@ export type {
144
142
  MovementCategory,
145
143
  VolumeLandmarks,
146
144
  } 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,
145
+ export type {
146
+ ActiveWorkoutPageProps,
147
+ ActiveWorkoutExercise,
148
+ ActiveWorkoutSuperset,
149
+ ActiveWorkoutInput,
150
+ ActiveWorkoutRest,
151
+ ActiveExerciseStatus,
152
+ WorkoutProgress,
153
+ WorkoutGroup,
163
154
  } from './ActiveWorkoutPage'
package/src/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  // Components
4
4
  export * from './components'
5
5
 
6
- // Theme
7
- export * from './theme'
6
+ // Theme (nativewind-free surface; ThemeProvider value lives on the /theme
7
+ // subpath so the root barrel does not pull the nativewind import chain)
8
+ export * from './theme/barrel'
8
9
 
9
10
  // Utilities
10
11
  export * from './utils'
package/src/pages.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Subpath entry: @titan-design/react-ui/pages
2
+ //
3
+ // Page-level organisms — phone-shaped reference screens (ActiveWorkoutPage,
4
+ // ExerciseDetailPage, ProgramPlanningPage). Demoted out of the root barrel for
5
+ // titan 0.5.0: they are full app screens, orphaned by both consumers, and their
6
+ // successors are the form-factor-responsive level views (plan B2). Their VALUE
7
+ // exports (components + derivation helpers) live HERE; type-only re-exports
8
+ // stay on the root barrel so consumers can still type props without importing
9
+ // the screens. Mirrors the `/bodymap` isolation pattern.
10
+ //
11
+ // TrainingStatusPage is NOT here — it depends on react-native-body-highlighter
12
+ // and lives under `@titan-design/react-ui/bodymap`.
13
+ export {
14
+ ActiveWorkoutPage,
15
+ countCompletedSets,
16
+ deriveWorkoutProgress,
17
+ findActiveExercise,
18
+ statusToCardState,
19
+ toActiveCardProps,
20
+ groupExercises,
21
+ type ActiveWorkoutPageProps,
22
+ type ActiveWorkoutExercise,
23
+ type ActiveWorkoutSuperset,
24
+ type ActiveWorkoutInput,
25
+ type ActiveWorkoutRest,
26
+ type ActiveExerciseStatus,
27
+ type WorkoutProgress,
28
+ type WorkoutGroup,
29
+ } from './components/custom/Workout/ActiveWorkoutPage'
30
+ export {
31
+ ExerciseDetailPage,
32
+ deriveExerciseStats,
33
+ toExerciseCardProps,
34
+ summarizeVbt,
35
+ EXERCISE_DETAIL_TABS,
36
+ type ExerciseDetailPageProps,
37
+ type ExerciseDetailTab,
38
+ type ExerciseDetailHeader,
39
+ type ExerciseDetailEntry,
40
+ type ExerciseTrend,
41
+ type ExerciseVbtSet,
42
+ type ExerciseVbt,
43
+ type ExerciseDetailStats,
44
+ type VbtSummary,
45
+ } from './components/custom/Workout/ExerciseDetailPage'
46
+ export {
47
+ ProgramPlanningPage,
48
+ deriveNavLevel,
49
+ toProgressBarMesos,
50
+ findMeso,
51
+ findWeek,
52
+ findWorkout,
53
+ buildBreadcrumbs,
54
+ type ProgramPlanningPageProps,
55
+ type PlanMeso,
56
+ type PlanWeek,
57
+ type PlanWorkout,
58
+ type ProgramNavLevel,
59
+ type ProgramSelection,
60
+ type ProgramBreadcrumb,
61
+ } from './components/custom/Workout/ProgramPlanningPage'