@titan-design/react-ui 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.js +232 -185
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1963 -11
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +193 -744
  10. package/dist/index.d.ts +193 -744
  11. package/dist/index.js +1551 -1856
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +10770 -8213
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/pages-D7Jlssvp.d.ts +791 -0
  16. package/dist/pages-MO0JCySL.d.mts +791 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.d.ts +4 -0
  19. package/dist/pages.js +6694 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6674 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +473 -211
  24. package/dist/theme/index.d.ts +473 -211
  25. package/dist/theme/index.js +341 -127
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +2085 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.js +148 -139
  30. package/dist/theme/tokens-css.js.map +1 -1
  31. package/dist/theme/tokens-css.mjs +582 -1
  32. package/dist/theme/tokens-css.mjs.map +1 -1
  33. package/package.json +11 -7
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
  35. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  36. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  37. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  38. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  39. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  40. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  41. package/src/components/custom/Typography/Typography.stories.tsx +45 -27
  42. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  43. package/src/components/custom/Typography/Typography.tsx +5 -0
  44. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
  45. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  46. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  47. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  48. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  49. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  50. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  51. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  52. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  53. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +19 -5
  56. package/src/components/custom/Workout/InputBar.tsx +2 -1
  57. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  58. package/src/components/custom/Workout/IntensityBar.tsx +113 -58
  59. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  60. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  61. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  62. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  63. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  64. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  65. package/src/components/custom/Workout/PrBadge.tsx +7 -4
  66. package/src/components/custom/Workout/PrHistoryModal.tsx +6 -5
  67. package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
  68. package/src/components/custom/Workout/README.md +50 -0
  69. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  70. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  71. package/src/components/custom/Workout/RestTimer.tsx +50 -44
  72. package/src/components/custom/Workout/SetRow.tsx +12 -2
  73. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  74. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  75. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  76. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  77. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  78. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  79. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  80. package/src/components/custom/Workout/TempoDisplay.tsx +109 -3
  81. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  82. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  83. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  84. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  85. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  86. package/src/components/custom/Workout/WeightBadge.tsx +8 -4
  87. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  88. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  89. package/src/components/custom/Workout/icons.tsx +7 -0
  90. package/src/components/custom/Workout/index.ts +38 -47
  91. package/src/components/icons/SvgIcon.tsx +50 -0
  92. package/src/components/icons/icons.stories.tsx +57 -0
  93. package/src/components/icons/icons.test.tsx +43 -0
  94. package/src/components/icons/icons.tsx +45 -0
  95. package/src/components/icons/index.ts +4 -0
  96. package/src/components/index.ts +6 -0
  97. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  98. package/src/components/shell/BrandLockup.test.tsx +23 -0
  99. package/src/components/shell/BrandLockup.tsx +47 -0
  100. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  101. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  102. package/src/components/shell/DeviceIndicator.tsx +52 -0
  103. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  104. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  105. package/src/components/shell/DeviceMenu.tsx +65 -0
  106. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  107. package/src/components/shell/DeviceRow.test.tsx +37 -0
  108. package/src/components/shell/DeviceRow.tsx +58 -0
  109. package/src/components/shell/README.md +86 -0
  110. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  111. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  112. package/src/components/shell/SessionStatePill.tsx +46 -0
  113. package/src/components/shell/TopBar.stories.tsx +49 -0
  114. package/src/components/shell/TopBar.test.tsx +36 -0
  115. package/src/components/shell/TopBar.tsx +89 -0
  116. package/src/components/shell/index.ts +8 -0
  117. package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
  118. package/src/components/ui/indicator/Indicator.test.tsx +19 -1
  119. package/src/components/ui/indicator/Indicator.tsx +45 -6
  120. package/src/components/ui/indicator/index.ts +1 -1
  121. package/src/components/ui/spinner/Spinner.tsx +3 -2
  122. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  123. package/src/index.ts +3 -2
  124. package/src/pages.ts +61 -0
  125. package/src/test/stories-smoke.test.tsx +50 -0
  126. package/src/theme/ColorSystem.stories.tsx +323 -0
  127. package/src/theme/Colors.stories.tsx +69 -1
  128. package/src/theme/barrel.ts +54 -0
  129. package/src/theme/config.ts +16 -3
  130. package/src/theme/extracted-colors-dataviz.ts +21 -0
  131. package/src/theme/extracted-colors-ui.ts +20 -0
  132. package/src/theme/global.css +64 -52
  133. package/src/theme/gradients.test.ts +25 -0
  134. package/src/theme/gradients.ts +36 -0
  135. package/src/theme/index.ts +8 -43
  136. package/src/theme/native-theming-guard.test.ts +117 -0
  137. package/src/theme/tokens/primitives.test.ts +232 -0
  138. package/src/theme/tokens/primitives.ts +247 -0
  139. package/src/theme/tokens/semantic.ts +93 -81
  140. package/src/theme/workout-tokens.ts +22 -21
  141. package/src/utils/avatar-color.ts +3 -3
  142. package/tailwind.config.js +10 -3
  143. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  144. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  145. package/dist/chunk-2SISKTWM.mjs +0 -1027
  146. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  147. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  148. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  149. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  150. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -1,12 +1,13 @@
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,
7
7
  type BaseBadgeSize,
8
8
  type BaseBadgeProps,
9
9
  } from './BaseBadge'
10
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
10
11
 
11
12
  export type WeightBadgeSize = BaseBadgeSize
12
13
 
@@ -42,7 +43,7 @@ export function WeightBadge({
42
43
  ...props
43
44
  }: WeightBadgeProps) {
44
45
  const config = baseBadgeSizeConfig[size]
45
- const textColor = isPr ? '#FF7900' : '#9CA3AF'
46
+ const textColor = isPr ? getSemanticColors('dark')['brand-primary'] : '#9CA3AF'
46
47
  // Dumbbell reads clearly as a weight/strength metric and is unit-agnostic
47
48
  // across estimated-1RM and N-rep-max contexts (vs. TrendingUp which implies
48
49
  // change/trend rather than a load value).
@@ -61,7 +62,7 @@ export function WeightBadge({
61
62
  className={className}
62
63
  icon={
63
64
  showIcon ? (
64
- <Dumbbell size={config.iconSize} color={iconColor} strokeWidth={2} />
65
+ <DumbbellIcon size={config.iconSize} color={iconColor} strokeWidth={2} />
65
66
  ) : undefined
66
67
  }
67
68
  accessibilityLabel={fullLabel}
@@ -100,7 +101,10 @@ export function WeightBadge({
100
101
  fontFamily: '"Space Grotesk", sans-serif',
101
102
  fontSize: config.fontSize,
102
103
  marginLeft: 4,
103
- color: delta >= 0 ? '#4caf50' : '#ef5350',
104
+ color:
105
+ delta >= 0
106
+ ? getSemanticColors('dark')['result-improve']
107
+ : getSemanticColors('dark')['result-degrade'],
104
108
  }}
105
109
  testID="weight-badge-delta"
106
110
  >
@@ -6,6 +6,9 @@ import {
6
6
  MuscleGroupChip,
7
7
  type VolumeStatus,
8
8
  } from './MuscleGroupChip'
9
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
10
+
11
+ const t = getSemanticColors('dark')
9
12
 
10
13
  export type WorkoutStatus = 'completed' | 'today' | 'upcoming'
11
14
 
@@ -43,8 +46,8 @@ export interface WorkoutCardProps extends ViewProps {
43
46
  }
44
47
 
45
48
  const COLORS = {
46
- statusSuccess: '#14B8A6',
47
- brandPrimary: '#FF7900',
49
+ statusSuccess: t['status-success'],
50
+ brandPrimary: t['brand-primary'],
48
51
  borderDefault: '#1F1F1F',
49
52
  brandPrimarySubtle: 'rgba(255,121,0,0.06)',
50
53
  }
@@ -1,6 +1,10 @@
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 { getSemanticColors } from '../../../theme/tokens/semantic'
5
+ import { WORKOUT_PILL_DELOAD } from '../../../theme/extracted-colors-dataviz'
6
+
7
+ const t = getSemanticColors('dark')
4
8
 
5
9
  /**
6
10
  * Spec statuses: completed | current | upcoming | deload.
@@ -19,30 +23,30 @@ export interface WorkoutPillProps extends ViewProps {
19
23
  }
20
24
 
21
25
  const statusBgColors: Record<WorkoutPillStatus, string> = {
22
- completed: 'rgba(20,184,166,0.15)',
26
+ completed: 'rgba(46,213,115,0.15)',
23
27
  current: 'rgba(255,121,0,0.15)',
24
28
  next: 'transparent',
25
29
  upcoming: '#1C1C1C',
26
30
  missed: 'rgba(209,67,67,0.1)',
27
- deload: 'rgba(147,51,234,0.15)',
31
+ deload: 'rgba(186,41,150,0.15)',
28
32
  }
29
33
 
30
34
  const statusBorderStyles: Record<WorkoutPillStatus, Record<string, unknown>> = {
31
- completed: { borderWidth: 1, borderColor: 'rgba(20,184,166,0.3)' },
35
+ completed: { borderWidth: 1, borderColor: 'rgba(46,213,115,0.3)' },
32
36
  current: { borderWidth: 1, borderColor: 'rgba(255,121,0,0.3)' },
33
37
  next: { borderWidth: 1, borderColor: 'rgba(255,121,0,0.4)' },
34
38
  upcoming: { borderWidth: 1, borderColor: '#1F1F1F' },
35
39
  missed: { borderWidth: 1, borderColor: 'rgba(209,67,67,0.25)' },
36
- deload: { borderWidth: 1, borderColor: 'rgba(147,51,234,0.3)' },
40
+ deload: { borderWidth: 1, borderColor: 'rgba(186,41,150,0.3)' },
37
41
  }
38
42
 
39
43
  const textColors: Record<WorkoutPillStatus, string> = {
40
- completed: '#14B8A6',
41
- current: '#FF7900',
42
- next: '#FF7900',
44
+ completed: t['status-success'],
45
+ current: t['brand-primary'],
46
+ next: t['brand-primary'],
43
47
  upcoming: '#6B7280',
44
48
  missed: 'rgba(209,67,67,0.7)',
45
- deload: '#9333ea',
49
+ deload: WORKOUT_PILL_DELOAD,
46
50
  }
47
51
 
48
52
  function usePulse(enabled: boolean) {
@@ -0,0 +1,7 @@
1
+ // Workout icons were promoted to the shared icon primitive
2
+ // (`components/icons`). This shim re-exports them so existing Workout consumers
3
+ // keep their local `./icons` import unchanged.
4
+ export { DumbbellIcon, StarIcon } from '../../icons'
5
+
6
+ /** @deprecated Use `IconProps` from `components/icons`. */
7
+ export type { IconProps as WorkoutIconProps } from '../../icons'
@@ -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'
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface IconProps {
4
+ /** Rendered width/height in px (viewBox is fixed 24×24). */
5
+ size?: number
6
+ /** Stroke color (or fill, for solid icons). Defaults to `currentColor` so a wrapper's text token drives it. */
7
+ color?: string
8
+ /** Stroke width for outline icons. */
9
+ strokeWidth?: number
10
+ /** Fill color; defaults to `none` (outline). */
11
+ fill?: string
12
+ /** Accessible title — sets `role="img"` + `<title>`. Omit for a decorative icon (hidden from AT). */
13
+ title?: string
14
+ }
15
+
16
+ export interface SvgIconProps extends IconProps {
17
+ children: ReactNode
18
+ }
19
+
20
+ /**
21
+ * Base SVG wrapper for the titan icon set — a 24×24 web-`<svg>` with the shared
22
+ * accessibility contract (titled → `role="img"` + `<title>`; untitled →
23
+ * `aria-hidden`) and `currentColor` inheritance. Icons pass their paths as
24
+ * children plus their stroke/fill defaults.
25
+ */
26
+ export function SvgIcon({
27
+ size = 24,
28
+ color = 'currentColor',
29
+ strokeWidth = 2,
30
+ fill = 'none',
31
+ title,
32
+ children,
33
+ }: SvgIconProps) {
34
+ return (
35
+ <svg
36
+ width={size}
37
+ height={size}
38
+ viewBox="0 0 24 24"
39
+ fill={fill}
40
+ stroke={color}
41
+ strokeWidth={strokeWidth}
42
+ strokeLinecap="round"
43
+ strokeLinejoin="round"
44
+ {...(title ? { role: 'img', 'aria-label': title } : { 'aria-hidden': true })}
45
+ >
46
+ {title ? <title>{title}</title> : null}
47
+ {children}
48
+ </svg>
49
+ )
50
+ }
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import type { ReactNode } from 'react'
3
+ import { View, Text } from 'react-native'
4
+ import { VoltrasMark, BluetoothIcon, DumbbellIcon, StarIcon } from './icons'
5
+
6
+ const meta: Meta = {
7
+ title: 'Icons',
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'The shared icon primitive — 24×24 web-`<svg>` glyphs on a common `SvgIcon` base ' +
14
+ '(a11y contract: titled → `role="img"`, untitled → `aria-hidden`; `currentColor` inheritance). ' +
15
+ 'Reusable design-system-wide (the shell, Workout badges, etc. compose these).',
16
+ },
17
+ },
18
+ },
19
+ }
20
+ export default meta
21
+ type Story = StoryObj
22
+
23
+ function Swatch({ label, children }: { label: string; children: ReactNode }) {
24
+ return (
25
+ <View className="items-center gap-2">
26
+ {children}
27
+ <Text className="font-mono text-[10px] text-text-tertiary">{label}</Text>
28
+ </View>
29
+ )
30
+ }
31
+
32
+ export const All: Story = {
33
+ render: () => (
34
+ <View className="flex-row gap-8 p-4 items-start">
35
+ <Swatch label="VoltrasMark">
36
+ <View className="text-brand-primary">
37
+ <VoltrasMark size={28} />
38
+ </View>
39
+ </Swatch>
40
+ <Swatch label="BluetoothIcon">
41
+ <View className="text-status-success">
42
+ <BluetoothIcon size={28} />
43
+ </View>
44
+ </Swatch>
45
+ <Swatch label="DumbbellIcon">
46
+ <View className="text-text-primary">
47
+ <DumbbellIcon size={28} />
48
+ </View>
49
+ </Swatch>
50
+ <Swatch label="StarIcon (fill)">
51
+ <View className="text-brand-primary">
52
+ <StarIcon size={28} fill="currentColor" />
53
+ </View>
54
+ </Swatch>
55
+ </View>
56
+ ),
57
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { VoltrasMark, BluetoothIcon, DumbbellIcon, StarIcon } from './icons'
5
+ import { SvgIcon } from './SvgIcon'
6
+
7
+ describe('icon primitives', () => {
8
+ it('every icon renders an svg', () => {
9
+ ;[VoltrasMark, BluetoothIcon, DumbbellIcon, StarIcon].forEach((Icon) => {
10
+ const { container, unmount } = render(<Icon />)
11
+ expect(container.querySelector('svg')).toBeInTheDocument()
12
+ unmount()
13
+ })
14
+ })
15
+
16
+ it('a titled icon is exposed as an accessible image', () => {
17
+ render(<BluetoothIcon title="Devices" />)
18
+ expect(screen.getByRole('img', { name: 'Devices' })).toBeInTheDocument()
19
+ })
20
+
21
+ it('an untitled icon is decorative (aria-hidden, no role)', () => {
22
+ const { container } = render(<VoltrasMark />)
23
+ const svg = container.querySelector('svg')!
24
+ expect(svg.getAttribute('aria-hidden')).toBe('true')
25
+ expect(svg.getAttribute('role')).toBeNull()
26
+ })
27
+
28
+ it('honors size + strokeWidth via the SvgIcon base', () => {
29
+ const { container } = render(
30
+ <SvgIcon size={32} strokeWidth={3}>
31
+ <path d="M0 0" />
32
+ </SvgIcon>
33
+ )
34
+ const svg = container.querySelector('svg')!
35
+ expect(svg.getAttribute('width')).toBe('32')
36
+ expect(svg.getAttribute('stroke-width')).toBe('3')
37
+ })
38
+
39
+ it('a decorative icon has no accessibility violations', async () => {
40
+ const { container } = render(<StarIcon />)
41
+ expect(await axe(container)).toHaveNoViolations()
42
+ })
43
+ })
@@ -0,0 +1,45 @@
1
+ import { SvgIcon, type IconProps } from './SvgIcon'
2
+
3
+ /**
4
+ * Voltras brand mark — the ◇ diamond (outline). Pass `fill` for a solid mark.
5
+ */
6
+ export function VoltrasMark({ size = 14, ...props }: IconProps) {
7
+ return (
8
+ <SvgIcon size={size} {...props}>
9
+ <path d="M12 2 22 12 12 22 2 12Z" />
10
+ </SvgIcon>
11
+ )
12
+ }
13
+
14
+ /**
15
+ * Bluetooth glyph (stroked) — e.g. device-connection state (color via `currentColor`).
16
+ */
17
+ export function BluetoothIcon({ size = 18, ...props }: IconProps) {
18
+ return (
19
+ <SvgIcon size={size} {...props}>
20
+ <path d="M7 7 17 17 12 22 12 2 17 7 7 17" />
21
+ </SvgIcon>
22
+ )
23
+ }
24
+
25
+ /** Dumbbell glyph (mirrors lucide-react `Dumbbell`). Outline-only by default. */
26
+ export function DumbbellIcon(props: IconProps) {
27
+ return (
28
+ <SvgIcon {...props}>
29
+ <path d="M14.4 14.4 9.6 9.6" />
30
+ <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" />
31
+ <path d="m21.5 21.5-1.4-1.4" />
32
+ <path d="M3.9 3.9 2.5 2.5" />
33
+ <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" />
34
+ </SvgIcon>
35
+ )
36
+ }
37
+
38
+ /** Star glyph (mirrors lucide-react `Star`). Pass `fill` for a solid star. */
39
+ export function StarIcon(props: IconProps) {
40
+ return (
41
+ <SvgIcon {...props}>
42
+ <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" />
43
+ </SvgIcon>
44
+ )
45
+ }
@@ -0,0 +1,4 @@
1
+ // Icon primitives — the shared, design-system-wide icon set. A base `SvgIcon`
2
+ // wrapper (24×24 web SVG + a11y contract + currentColor) and the icon glyphs.
3
+ export { SvgIcon, type IconProps, type SvgIconProps } from './SvgIcon'
4
+ export * from './icons'
@@ -1,5 +1,11 @@
1
+ // Icon primitives (shared, design-system-wide)
2
+ export * from './icons'
3
+
1
4
  // Re-export all UI components
2
5
  export * from './ui'
3
6
 
4
7
  // Re-export custom components
5
8
  export * from './custom'
9
+
10
+ // Re-export shell chrome components
11
+ export * from './shell'
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { BrandLockup } from './BrandLockup'
3
+
4
+ const meta: Meta<typeof BrandLockup> = {
5
+ title: 'Shell/TopBar/BrandLockup',
6
+ component: BrandLockup,
7
+ tags: ['autodocs'],
8
+ args: { subtitle: 'wall dashboard', showSubtitle: true },
9
+ argTypes: {
10
+ subtitle: { control: 'text' },
11
+ showSubtitle: { control: 'boolean' },
12
+ },
13
+ parameters: {
14
+ docs: {
15
+ description: {
16
+ component:
17
+ '**Molecule.** Composes [VoltrasMark](?path=/docs/icons--docs) (icon) + ' +
18
+ '[Typography](?path=/docs/custom-typography--docs) (wordmark + subtitle). ' +
19
+ 'Toggle `showSubtitle` to see the responsive collapse.',
20
+ },
21
+ },
22
+ },
23
+ }
24
+ export default meta
25
+ type Story = StoryObj<typeof BrandLockup>
26
+
27
+ export const Default: Story = {}
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { BrandLockup } from './BrandLockup'
5
+
6
+ describe('BrandLockup', () => {
7
+ it('renders the wordmark', () => {
8
+ render(<BrandLockup />)
9
+ expect(screen.getByText('VOLTRAS')).toBeInTheDocument()
10
+ })
11
+
12
+ it('shows the subtitle by default and hides it when showSubtitle is false', () => {
13
+ const { rerender } = render(<BrandLockup subtitle="wall dashboard" />)
14
+ expect(screen.getByText('/ wall dashboard')).toBeInTheDocument()
15
+ rerender(<BrandLockup subtitle="wall dashboard" showSubtitle={false} />)
16
+ expect(screen.queryByText('/ wall dashboard')).not.toBeInTheDocument()
17
+ })
18
+
19
+ it('has no accessibility violations', async () => {
20
+ const { container } = render(<BrandLockup />)
21
+ expect(await axe(container)).toHaveNoViolations()
22
+ })
23
+ })
@@ -0,0 +1,47 @@
1
+ import { View, type ViewProps } from 'react-native'
2
+ import { cn } from '../../utils/cn'
3
+ import { Typography } from '../custom/Typography'
4
+ import { VoltrasMark } from '../icons'
5
+
6
+ export interface BrandLockupProps extends ViewProps {
7
+ /** The "/ subtitle" that follows the wordmark. Default "wall dashboard". */
8
+ subtitle?: string
9
+ /** Show the subtitle. Hidden at tablet width and below (S1-Q4 responsive). */
10
+ showSubtitle?: boolean
11
+ className?: string
12
+ }
13
+
14
+ /**
15
+ * Product identity lockup for the top bar: ◇ mark + VOLTRAS wordmark + optional
16
+ * subtitle. Composes the VoltrasMark icon + Typography. S1 · BrandLockup.
17
+ */
18
+ export function BrandLockup({
19
+ subtitle = 'wall dashboard',
20
+ showSubtitle = true,
21
+ className,
22
+ ...props
23
+ }: BrandLockupProps) {
24
+ return (
25
+ <View className={cn('flex-row items-center gap-[7px]', className)} {...props}>
26
+ <View className="text-brand-primary">
27
+ <VoltrasMark size={14} color="currentColor" />
28
+ </View>
29
+ <Typography
30
+ variant="button"
31
+ color="primary"
32
+ className="text-[12px] font-extrabold tracking-[1.5px]"
33
+ >
34
+ VOLTRAS
35
+ </Typography>
36
+ {showSubtitle && subtitle ? (
37
+ <Typography
38
+ variant="button"
39
+ color="tertiary"
40
+ className="text-[10px] font-semibold tracking-[0.4px]"
41
+ >
42
+ / {subtitle}
43
+ </Typography>
44
+ ) : null}
45
+ </View>
46
+ )
47
+ }
@@ -0,0 +1,28 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { DeviceIndicator } from './DeviceIndicator'
3
+
4
+ const meta: Meta<typeof DeviceIndicator> = {
5
+ title: 'Shell/TopBar/DeviceMenu/DeviceIndicator',
6
+ component: DeviceIndicator,
7
+ tags: ['autodocs'],
8
+ args: { status: 'connected' },
9
+ argTypes: {
10
+ status: { control: 'select', options: ['connected', 'degraded', 'lost'] },
11
+ label: { control: 'text' },
12
+ onPress: { control: false },
13
+ },
14
+ parameters: {
15
+ docs: {
16
+ description: {
17
+ component:
18
+ '**Molecule.** Composes [BluetoothIcon](?path=/docs/icons--docs), color-coded by ' +
19
+ 'connection state via a `text-status-*-vivid` token — the glyph color alone carries the fault ' +
20
+ '(no separate badge). Switch the `status` control to see connected / degraded / lost.',
21
+ },
22
+ },
23
+ },
24
+ }
25
+ export default meta
26
+ type Story = StoryObj<typeof DeviceIndicator>
27
+
28
+ export const Default: Story = {}
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { DeviceIndicator } from './DeviceIndicator'
4
+
5
+ describe('DeviceIndicator', () => {
6
+ it('renders each connection state', () => {
7
+ const states = ['connected', 'degraded', 'lost'] as const
8
+ states.forEach((status) => {
9
+ const { unmount, container } = render(<DeviceIndicator status={status} />)
10
+ expect(container.firstChild).toBeInTheDocument()
11
+ unmount()
12
+ })
13
+ })
14
+
15
+ it('exposes a button role and fires onPress when interactive', () => {
16
+ const onPress = vi.fn()
17
+ render(<DeviceIndicator onPress={onPress} />)
18
+ fireEvent.click(screen.getByRole('button', { name: 'Devices' }))
19
+ expect(onPress).toHaveBeenCalledTimes(1)
20
+ })
21
+
22
+ it('renders the lost state (red glyph, no separate badge)', () => {
23
+ const { container } = render(<DeviceIndicator status="lost" />)
24
+ expect(container.firstChild).toBeInTheDocument()
25
+ })
26
+ })
@@ -0,0 +1,52 @@
1
+ import { View, Pressable, type ViewProps } from 'react-native'
2
+ import { cn } from '../../utils/cn'
3
+ import { BluetoothIcon } from '../icons'
4
+
5
+ export type DeviceConnState = 'connected' | 'degraded' | 'lost'
6
+
7
+ export interface DeviceIndicatorProps extends ViewProps {
8
+ /** Aggregate connection state (worst-of the bound devices) → glyph color. */
9
+ status?: DeviceConnState
10
+ /** Tap handler. When omitted the glyph is non-interactive (e.g. inside a PopoverTrigger). */
11
+ onPress?: () => void
12
+ /** Accessible label (names the glyph / the wrapping trigger button). */
13
+ label?: string
14
+ className?: string
15
+ }
16
+
17
+ // currentColor drives the stroke → color the wrapper's text token by state (vivid, matches LIVE).
18
+ // The glyph color alone carries the fault (red on lost) — no separate alert badge.
19
+ const statusTextClass: Record<DeviceConnState, string> = {
20
+ connected: 'text-status-success',
21
+ degraded: 'text-status-warning',
22
+ lost: 'text-status-error-vivid',
23
+ }
24
+
25
+ /**
26
+ * The device/BLE connection glyph — a color-coded bluetooth mark, no label.
27
+ * Opens the DeviceMenu when tapped. S1 · DeviceIndicator.
28
+ */
29
+ export function DeviceIndicator({
30
+ status = 'connected',
31
+ onPress,
32
+ label = 'Devices',
33
+ className,
34
+ ...props
35
+ }: DeviceIndicatorProps) {
36
+ const Wrapper = onPress ? Pressable : View
37
+ return (
38
+ <Wrapper
39
+ accessibilityLabel={label}
40
+ {...(onPress ? { onPress, accessibilityRole: 'button' } : {})}
41
+ className={cn(
42
+ 'w-[26px] h-[28px] items-center justify-center rounded-[7px]',
43
+ statusTextClass[status],
44
+ className
45
+ )}
46
+ {...props}
47
+ >
48
+ {/* the SVG title names the wrapping trigger button (RNW drops aria-label on non-accessible Views) */}
49
+ <BluetoothIcon size={18} color="currentColor" title={label} />
50
+ </Wrapper>
51
+ )
52
+ }