@titan-design/react-ui 0.5.0 → 0.7.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 (307) hide show
  1. package/dist/bodymap.js +241 -189
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +241 -189
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +1017 -130
  6. package/dist/index.d.ts +1017 -130
  7. package/dist/index.js +3058 -1090
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2992 -1091
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-MO0JCySL.d.mts → pages-8u_4WbL-.d.mts} +280 -57
  12. package/dist/{pages-D7Jlssvp.d.ts → pages-DaWiBOGa.d.ts} +280 -57
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1797 -1292
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1797 -1292
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +464 -268
  20. package/dist/theme/index.d.ts +464 -268
  21. package/dist/theme/index.js +303 -191
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +295 -192
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +160 -143
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +160 -143
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +3 -1
  30. package/src/arch/Architecture.stories.tsx +870 -0
  31. package/src/arch/annotations.json +48 -0
  32. package/src/arch/arch-graph.json +4301 -0
  33. package/src/components/custom/DateTime/DateTime.stories.tsx +83 -7
  34. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  35. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  36. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  37. package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
  38. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  39. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  40. package/src/components/custom/Metric/Metric.stories.tsx +1 -1
  41. package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
  42. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  43. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  44. package/src/components/custom/Table/Table.stories.tsx +1 -1
  45. package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
  46. package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
  47. package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
  48. package/src/components/custom/TimerReadout/index.ts +1 -0
  49. package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
  50. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  51. package/src/components/custom/Typography/Typography.stories.tsx +46 -28
  52. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  53. package/src/components/custom/Typography/Typography.tsx +5 -0
  54. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
  55. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
  56. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +11 -16
  57. package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
  58. package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
  59. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  60. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  67. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
  70. package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
  71. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
  72. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
  73. package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
  74. package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
  75. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
  76. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
  77. package/src/components/custom/Workout/ExerciseDetailPage.tsx +6 -6
  78. package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
  79. package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
  80. package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
  81. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
  82. package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
  83. package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
  84. package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
  85. package/src/components/custom/Workout/FatigueMeter.test.tsx +73 -0
  86. package/src/components/custom/Workout/FatigueMeter.tsx +82 -0
  87. package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
  88. package/src/components/custom/Workout/InputBar.tsx +2 -1
  89. package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
  90. package/src/components/custom/Workout/IntensityBar.test.tsx +8 -8
  91. package/src/components/custom/Workout/IntensityBar.tsx +10 -6
  92. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
  93. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
  94. package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
  95. package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
  96. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  97. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
  98. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  99. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  100. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
  101. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  102. package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
  103. package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
  104. package/src/components/custom/Workout/MetricTiles.tsx +48 -0
  105. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
  106. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  107. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
  108. package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
  109. package/src/components/custom/Workout/PrBadge.tsx +5 -2
  110. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
  111. package/src/components/custom/Workout/PrHistoryModal.tsx +4 -3
  112. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
  113. package/src/components/custom/Workout/ProgramPlanningPage.tsx +10 -7
  114. package/src/components/custom/Workout/README.md +146 -4
  115. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
  116. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  117. package/src/components/custom/Workout/RestTimer.stories.tsx +1 -1
  118. package/src/components/custom/Workout/RestTimer.tsx +17 -11
  119. package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
  120. package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
  121. package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
  122. package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
  123. package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
  124. package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
  125. package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
  126. package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
  127. package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
  128. package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
  129. package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
  130. package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
  131. package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
  132. package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
  133. package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
  134. package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
  135. package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
  136. package/src/components/custom/Workout/SessionHeader.tsx +152 -0
  137. package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
  138. package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
  139. package/src/components/custom/Workout/SessionRail.tsx +126 -0
  140. package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
  141. package/src/components/custom/Workout/SetBar.test.tsx +123 -0
  142. package/src/components/custom/Workout/SetBar.tsx +171 -0
  143. package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
  144. package/src/components/custom/Workout/SetRow.test.tsx +94 -166
  145. package/src/components/custom/Workout/SetRow.tsx +170 -232
  146. package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
  147. package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
  148. package/src/components/custom/Workout/SetStrip.tsx +71 -0
  149. package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
  150. package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
  151. package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
  152. package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
  153. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
  154. package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
  155. package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
  156. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  157. package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
  158. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  159. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  160. package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
  161. package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
  162. package/src/components/custom/Workout/StatusPill.tsx +88 -0
  163. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
  164. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  165. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
  166. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  167. package/src/components/custom/Workout/TempoBar.stories.tsx +1 -1
  168. package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
  169. package/src/components/custom/Workout/TempoDisplay.tsx +29 -36
  170. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
  171. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
  172. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
  173. package/src/components/custom/Workout/VelocityStrip.stories.tsx +129 -66
  174. package/src/components/custom/Workout/VelocityStrip.test.tsx +379 -144
  175. package/src/components/custom/Workout/VelocityStrip.tsx +508 -120
  176. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
  177. package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
  178. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
  179. package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
  180. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  181. package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
  182. package/src/components/custom/Workout/WeightBadge.tsx +6 -2
  183. package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
  184. package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
  185. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  186. package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
  187. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  188. package/src/components/custom/Workout/ZoneTrack.stories.tsx +140 -0
  189. package/src/components/custom/Workout/ZoneTrack.test.tsx +200 -0
  190. package/src/components/custom/Workout/ZoneTrack.tsx +315 -0
  191. package/src/components/custom/Workout/icons.tsx +6 -67
  192. package/src/components/custom/Workout/index.ts +55 -2
  193. package/src/components/custom/Workout/metricText.stories.tsx +69 -0
  194. package/src/components/custom/Workout/metricText.tsx +34 -0
  195. package/src/components/custom/Workout/paceTone.test.ts +29 -0
  196. package/src/components/custom/Workout/paceTone.ts +27 -0
  197. package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
  198. package/src/components/custom/index.ts +1 -0
  199. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  200. package/src/components/icons/SvgIcon.tsx +50 -0
  201. package/src/components/icons/icons.stories.tsx +122 -0
  202. package/src/components/icons/icons.test.tsx +73 -0
  203. package/src/components/icons/icons.tsx +153 -0
  204. package/src/components/icons/index.ts +4 -0
  205. package/src/components/index.ts +6 -0
  206. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  207. package/src/components/shell/BrandLockup.test.tsx +23 -0
  208. package/src/components/shell/BrandLockup.tsx +47 -0
  209. package/src/components/shell/DashboardShell.stories.tsx +65 -0
  210. package/src/components/shell/DashboardShell.test.tsx +42 -0
  211. package/src/components/shell/DashboardShell.tsx +74 -0
  212. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  213. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  214. package/src/components/shell/DeviceIndicator.tsx +52 -0
  215. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  216. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  217. package/src/components/shell/DeviceMenu.tsx +65 -0
  218. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  219. package/src/components/shell/DeviceRow.test.tsx +37 -0
  220. package/src/components/shell/DeviceRow.tsx +58 -0
  221. package/src/components/shell/NavItem.stories.tsx +52 -0
  222. package/src/components/shell/NavItem.test.tsx +35 -0
  223. package/src/components/shell/NavItem.tsx +75 -0
  224. package/src/components/shell/README.md +116 -0
  225. package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
  226. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  227. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  228. package/src/components/shell/SessionStatePill.tsx +46 -0
  229. package/src/components/shell/SideNav.stories.tsx +81 -0
  230. package/src/components/shell/SideNav.test.tsx +51 -0
  231. package/src/components/shell/SideNav.tsx +74 -0
  232. package/src/components/shell/TopBar.stories.tsx +49 -0
  233. package/src/components/shell/TopBar.test.tsx +36 -0
  234. package/src/components/shell/TopBar.tsx +89 -0
  235. package/src/components/shell/index.ts +13 -0
  236. package/src/components/ui/alert/Alert.stories.tsx +1 -1
  237. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  238. package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
  239. package/src/components/ui/badge/Badge.stories.tsx +1 -1
  240. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
  241. package/src/components/ui/button/Button.stories.tsx +1 -1
  242. package/src/components/ui/card/Card.stories.tsx +1 -1
  243. package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
  244. package/src/components/ui/chip/Chip.stories.tsx +1 -1
  245. package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
  246. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  247. package/src/components/ui/divider/Divider.stories.tsx +1 -1
  248. package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
  249. package/src/components/ui/form-field/FormField.stories.tsx +1 -1
  250. package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
  251. package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
  252. package/src/components/ui/index.ts +1 -0
  253. package/src/components/ui/indicator/Indicator.stories.tsx +38 -2
  254. package/src/components/ui/indicator/Indicator.test.tsx +20 -1
  255. package/src/components/ui/indicator/Indicator.tsx +50 -6
  256. package/src/components/ui/indicator/index.ts +1 -1
  257. package/src/components/ui/input/Input.stories.tsx +1 -1
  258. package/src/components/ui/link/Link.stories.tsx +1 -1
  259. package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
  260. package/src/components/ui/menu/Menu.stories.tsx +1 -1
  261. package/src/components/ui/modal/Modal.stories.tsx +1 -1
  262. package/src/components/ui/pill/Pill.stories.tsx +1 -1
  263. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  264. package/src/components/ui/progress/Progress.stories.tsx +1 -1
  265. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  266. package/src/components/ui/section/Section.stories.tsx +1 -1
  267. package/src/components/ui/select/Select.stories.tsx +1 -1
  268. package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
  269. package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
  270. package/src/components/ui/spinner/Spinner.tsx +3 -2
  271. package/src/components/ui/stack/Stack.stories.tsx +1 -1
  272. package/src/components/ui/surface/Surface.stories.tsx +1 -1
  273. package/src/components/ui/switch/Switch.stories.tsx +1 -1
  274. package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
  275. package/src/components/ui/tile/Tile.stories.tsx +79 -0
  276. package/src/components/ui/tile/Tile.test.tsx +48 -0
  277. package/src/components/ui/tile/Tile.tsx +64 -0
  278. package/src/components/ui/tile/index.ts +2 -0
  279. package/src/components/ui/toast/Toast.stories.tsx +1 -1
  280. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  281. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  282. package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
  283. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
  284. package/src/hooks/index.ts +2 -0
  285. package/src/hooks/useTimer.test.ts +150 -0
  286. package/src/hooks/useTimer.ts +77 -0
  287. package/src/index.ts +3 -0
  288. package/src/pages.ts +1 -1
  289. package/src/stories/PresetShowcase.stories.tsx +1 -1
  290. package/src/stories/ThemePresets.stories.tsx +1 -1
  291. package/src/theme/ColorSystem.stories.tsx +323 -0
  292. package/src/theme/Colors.stories.tsx +74 -9
  293. package/src/theme/barrel.ts +1 -0
  294. package/src/theme/config.ts +20 -3
  295. package/src/theme/elevation.stories.tsx +1 -1
  296. package/src/theme/extracted-colors-dataviz.ts +21 -0
  297. package/src/theme/extracted-colors-ui.ts +20 -0
  298. package/src/theme/global.css +69 -53
  299. package/src/theme/gradients.test.ts +25 -0
  300. package/src/theme/gradients.ts +36 -0
  301. package/src/theme/shadows.stories.tsx +1 -1
  302. package/src/theme/tokens/primitives.test.ts +232 -0
  303. package/src/theme/tokens/primitives.ts +238 -87
  304. package/src/theme/tokens/semantic.ts +101 -81
  305. package/src/theme/workout-tokens.ts +26 -21
  306. package/src/utils/avatar-color.ts +3 -3
  307. package/tailwind.config.js +15 -3
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { DeviceMenu } from './DeviceMenu'
4
+ import { type Device } from './DeviceRow'
5
+
6
+ const DEVICES: Device[] = [
7
+ { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
8
+ { id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
9
+ { id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' },
10
+ ]
11
+
12
+ const meta: Meta<typeof DeviceMenu> = {
13
+ title: 'Shell/DeviceMenu',
14
+ component: DeviceMenu,
15
+ tags: ['autodocs'],
16
+ args: { devices: DEVICES, isOpen: false },
17
+ argTypes: {
18
+ devices: { control: 'object' },
19
+ isOpen: { control: 'boolean' },
20
+ onOpenChange: { control: false },
21
+ onSelectDevice: { control: false },
22
+ },
23
+ decorators: [
24
+ (Story) => (
25
+ <View className="flex-row p-4 pb-48">
26
+ <Story />
27
+ </View>
28
+ ),
29
+ ],
30
+ parameters: {
31
+ docs: {
32
+ description: {
33
+ component:
34
+ '**Organism.** The device glyph + its dropdown. Composes ' +
35
+ '[Popover](?path=/docs/components-molecules-popover--docs) + ' +
36
+ '[DeviceIndicator](?path=/docs/shell-deviceindicator--docs) (trigger) + ' +
37
+ '[DeviceRow](?path=/docs/shell-devicerow--docs) (list) + ' +
38
+ '[Typography](?path=/docs/foundations-typography--docs) (header). Aggregates the bound devices ' +
39
+ '(worst-of) for the glyph state. Click the glyph, or toggle the `isOpen` control.',
40
+ },
41
+ },
42
+ },
43
+ }
44
+ export default meta
45
+ type Story = StoryObj<typeof DeviceMenu>
46
+
47
+ export const Default: Story = {}
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { DeviceMenu } from './DeviceMenu'
4
+ import { type Device } from './DeviceRow'
5
+
6
+ const devices: Device[] = [
7
+ { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
8
+ { id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
9
+ { id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' },
10
+ ]
11
+
12
+ describe('DeviceMenu', () => {
13
+ it('renders the trigger glyph and opens the device list on click', () => {
14
+ render(<DeviceMenu devices={devices} />)
15
+ const trigger = screen.getByRole('button', { name: 'Devices' })
16
+ expect(screen.queryByText('Left Cable')).not.toBeInTheDocument()
17
+ fireEvent.click(trigger)
18
+ expect(screen.getByText('Left Cable')).toBeInTheDocument()
19
+ expect(screen.getByText(/2 bound · 1 available/)).toBeInTheDocument()
20
+ })
21
+
22
+ it('reports a controlled open state', () => {
23
+ render(<DeviceMenu devices={devices} isOpen />)
24
+ expect(screen.getByText('Right Cable')).toBeInTheDocument()
25
+ })
26
+
27
+ it('fires onSelectDevice when a row is tapped', () => {
28
+ const onSelectDevice = vi.fn()
29
+ render(<DeviceMenu devices={devices} isOpen onSelectDevice={onSelectDevice} />)
30
+ fireEvent.click(screen.getByText('Left Cable'))
31
+ expect(onSelectDevice).toHaveBeenCalledWith(expect.objectContaining({ id: 'Voltra-A3F2' }))
32
+ })
33
+
34
+ it('renders regardless of the aggregate worst-of state (degraded / lost bound device)', () => {
35
+ const degraded: Device[] = [{ id: 'D', nickname: 'Left', slot: 'L', state: 'degraded' }]
36
+ const lost: Device[] = [{ id: 'L', nickname: 'Left', slot: 'L', state: 'lost' }]
37
+ expect(render(<DeviceMenu devices={degraded} />).container.firstChild).toBeInTheDocument()
38
+ expect(render(<DeviceMenu devices={lost} />).container.firstChild).toBeInTheDocument()
39
+ })
40
+ })
@@ -0,0 +1,65 @@
1
+ import { View } from 'react-native'
2
+ import { cn } from '../../utils/cn'
3
+ import { Typography } from '../custom/Typography'
4
+ import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover'
5
+ import { DeviceIndicator, type DeviceConnState } from './DeviceIndicator'
6
+ import { DeviceRow, type Device } from './DeviceRow'
7
+
8
+ export interface DeviceMenuProps {
9
+ devices: Device[]
10
+ /** Controlled open state (optional — uncontrolled otherwise). */
11
+ isOpen?: boolean
12
+ onOpenChange?: (open: boolean) => void
13
+ onSelectDevice?: (device: Device) => void
14
+ className?: string
15
+ }
16
+
17
+ /** Aggregate the bound devices into a single glyph state (worst-of). */
18
+ function aggregateStatus(devices: Device[]): DeviceConnState {
19
+ const bound = devices.filter((d) => d.slot)
20
+ if (bound.some((d) => d.state === 'lost')) return 'lost'
21
+ if (bound.some((d) => d.state === 'degraded')) return 'degraded'
22
+ return 'connected'
23
+ }
24
+
25
+ /**
26
+ * The device glyph + its dropdown of individual devices (availability, slot
27
+ * binding, id, connection state). Composes Popover + DeviceIndicator +
28
+ * DeviceRow. S1 · DeviceMenu.
29
+ */
30
+ export function DeviceMenu({
31
+ devices,
32
+ isOpen,
33
+ onOpenChange,
34
+ onSelectDevice,
35
+ className,
36
+ }: DeviceMenuProps) {
37
+ const status = aggregateStatus(devices)
38
+ const boundCount = devices.filter((d) => d.slot).length
39
+ const availCount = devices.filter((d) => !d.slot).length
40
+
41
+ return (
42
+ <Popover placement="bottom" isOpen={isOpen} onOpenChange={onOpenChange} className={className}>
43
+ <PopoverTrigger>
44
+ <DeviceIndicator status={status} />
45
+ </PopoverTrigger>
46
+ <PopoverContent className="right-0 left-auto mt-[10px] w-[326px] bg-surface-elevated border border-border-strong rounded-[10px] p-[7px]">
47
+ <Typography
48
+ variant="monoLabel"
49
+ color="tertiary"
50
+ className="text-[9px] tracking-[0.8px] px-2 pt-[6px] pb-2"
51
+ >
52
+ Devices — {boundCount} bound · {availCount} available
53
+ </Typography>
54
+ {devices.map((device, i) => (
55
+ <View key={device.id} className={cn(i > 0 && 'border-t border-border')}>
56
+ <DeviceRow
57
+ device={device}
58
+ onPress={onSelectDevice ? () => onSelectDevice(device) : undefined}
59
+ />
60
+ </View>
61
+ ))}
62
+ </PopoverContent>
63
+ </Popover>
64
+ )
65
+ }
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { DeviceRow } from './DeviceRow'
4
+
5
+ const meta: Meta<typeof DeviceRow> = {
6
+ title: 'Shell/DeviceRow',
7
+ component: DeviceRow,
8
+ tags: ['autodocs'],
9
+ args: {
10
+ device: { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
11
+ },
12
+ argTypes: {
13
+ device: { control: 'object' },
14
+ onPress: { control: false },
15
+ },
16
+ decorators: [
17
+ (Story) => (
18
+ <View className="w-[320px] bg-surface-elevated rounded-lg p-1">
19
+ <Story />
20
+ </View>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ '**Molecule.** One device row: status dot · name · Bluetooth id. Composes ' +
28
+ '[Indicator](?path=/docs/components-atoms-indicator--docs) (status dot) + ' +
29
+ '[Typography](?path=/docs/foundations-typography--docs) (`body2` name, `mono` id). ' +
30
+ 'Edit the `device` control (set `state` to `lost` / `available`, `slot` to `null`) to explore.',
31
+ },
32
+ },
33
+ },
34
+ }
35
+ export default meta
36
+ type Story = StoryObj<typeof DeviceRow>
37
+
38
+ export const Default: Story = {}
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { DeviceRow, type Device } from './DeviceRow'
4
+
5
+ const bound: Device = { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' }
6
+ const unbound: Device = { id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' }
7
+
8
+ describe('DeviceRow', () => {
9
+ it('renders the device name and Bluetooth id', () => {
10
+ render(<DeviceRow device={bound} />)
11
+ expect(screen.getByText('Left Cable')).toBeInTheDocument()
12
+ expect(screen.getByText('Voltra-A3F2')).toBeInTheDocument()
13
+ })
14
+
15
+ it('omits the redundant status text and slot pill (dot + name carry it)', () => {
16
+ render(<DeviceRow device={bound} />)
17
+ expect(screen.queryByText('connected')).not.toBeInTheDocument()
18
+ expect(screen.queryByText('SLOT L')).not.toBeInTheDocument()
19
+ })
20
+
21
+ it('renders an unbound device by name', () => {
22
+ render(<DeviceRow device={unbound} />)
23
+ expect(screen.getByText('Spare')).toBeInTheDocument()
24
+ expect(screen.getByText('Voltra-77E0')).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders every connection state without crashing', () => {
28
+ const states = ['connected', 'available', 'degraded', 'lost'] as const
29
+ states.forEach((state) => {
30
+ const { unmount, container } = render(
31
+ <DeviceRow device={{ id: 'X', nickname: 'Cable', slot: 'L', state }} />
32
+ )
33
+ expect(container.firstChild).toBeInTheDocument()
34
+ unmount()
35
+ })
36
+ })
37
+ })
@@ -0,0 +1,58 @@
1
+ import { View, Pressable, type ViewProps } from 'react-native'
2
+ import { cn } from '../../utils/cn'
3
+ import { Indicator, type IndicatorColor } from '../ui/indicator'
4
+ import { Typography } from '../custom/Typography'
5
+
6
+ export type DeviceRowState = 'connected' | 'available' | 'degraded' | 'lost'
7
+
8
+ export interface Device {
9
+ /** Stable device id (e.g. "Voltra-A3F2"). */
10
+ id: string
11
+ /** User-assigned slot/device name (e.g. "Left Cable"). */
12
+ nickname: string
13
+ /** Bound slot, or null when unbound/available (used for the menu's bound/available tally). */
14
+ slot?: 'L' | 'R' | null
15
+ /** Connection/availability state → the status dot color. */
16
+ state: DeviceRowState
17
+ }
18
+
19
+ export interface DeviceRowProps extends ViewProps {
20
+ device: Device
21
+ onPress?: () => void
22
+ className?: string
23
+ }
24
+
25
+ const stateColor: Record<DeviceRowState, IndicatorColor> = {
26
+ connected: 'success',
27
+ available: 'info',
28
+ degraded: 'warning',
29
+ lost: 'error-vivid',
30
+ }
31
+
32
+ /**
33
+ * One device in the DeviceMenu: status dot · name · Bluetooth id. The dot color
34
+ * carries the connection state (no redundant status text); the name is the one
35
+ * slot label (no duplicate slot badge). S1 · DeviceRow.
36
+ */
37
+ export function DeviceRow({ device, onPress, className, ...props }: DeviceRowProps) {
38
+ const Wrapper = onPress ? Pressable : View
39
+ return (
40
+ <Wrapper
41
+ {...(onPress ? { onPress, accessibilityRole: 'button' } : {})}
42
+ className={cn('flex-row items-center gap-[10px] px-2 py-[9px] rounded-[7px]', className)}
43
+ {...props}
44
+ >
45
+ <Indicator size="md" color={stateColor[device.state]} />
46
+ <Typography
47
+ variant="body2"
48
+ color={device.slot ? 'primary' : 'secondary'}
49
+ className="flex-1 font-bold text-[12px]"
50
+ >
51
+ {device.nickname}
52
+ </Typography>
53
+ <Typography variant="mono" color="tertiary" className="text-[10px]">
54
+ {device.id}
55
+ </Typography>
56
+ </Wrapper>
57
+ )
58
+ }
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { NavItem } from './NavItem'
4
+ import { ActivityIcon } from '../icons'
5
+
6
+ const meta: Meta<typeof NavItem> = {
7
+ title: 'Shell/NavItem',
8
+ component: NavItem,
9
+ tags: ['autodocs'],
10
+ args: { label: 'Live', active: false, live: false },
11
+ argTypes: {
12
+ label: { control: 'text' },
13
+ active: { control: 'boolean' },
14
+ live: { control: 'boolean' },
15
+ icon: { control: false },
16
+ onPress: { action: 'press' },
17
+ },
18
+ decorators: [
19
+ (Story) => (
20
+ <View className="w-[60px] items-center bg-background-base py-2">
21
+ <Story />
22
+ </View>
23
+ ),
24
+ ],
25
+ parameters: {
26
+ docs: {
27
+ description: {
28
+ component:
29
+ '**Molecule.** One category button — a glyph over an uppercase micro-label in a 46×46 target, ' +
30
+ 'spanning the full 60px rail so the active accent bar sits flush to the edge. Composes an ' +
31
+ '[icon](?path=/docs/foundations-icons--docs) + [Typography](?path=/docs/foundations-typography--docs). Toggle ' +
32
+ '`active` / `live` to see the states.',
33
+ },
34
+ },
35
+ },
36
+ render: (args) => <NavItem {...args} icon={<ActivityIcon size={20} color="currentColor" />} />,
37
+ }
38
+ export default meta
39
+ type Story = StoryObj<typeof NavItem>
40
+
41
+ export const Default: Story = {}
42
+ export const Active: Story = { args: { active: true } }
43
+ export const LiveElsewhere: Story = {
44
+ args: { live: true },
45
+ parameters: {
46
+ docs: {
47
+ description: {
48
+ story: 'Not the active view, but a set is running for this category → muted-green label.',
49
+ },
50
+ },
51
+ },
52
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { NavItem } from './NavItem'
4
+ import { ActivityIcon } from '../icons'
5
+
6
+ const icon = <ActivityIcon size={20} color="currentColor" />
7
+
8
+ describe('NavItem', () => {
9
+ it('renders the label and is exposed as a tab', () => {
10
+ render(<NavItem icon={icon} label="Live" />)
11
+ expect(screen.getByRole('tab', { name: 'Live' })).toBeInTheDocument()
12
+ })
13
+
14
+ it('shows the accent bar when active', () => {
15
+ render(<NavItem icon={icon} label="Live" active />)
16
+ expect(screen.getByTestId('nav-item-accent')).toBeInTheDocument()
17
+ })
18
+
19
+ it('hides the accent bar when inactive', () => {
20
+ render(<NavItem icon={icon} label="Live" />)
21
+ expect(screen.queryByTestId('nav-item-accent')).toBeNull()
22
+ })
23
+
24
+ it('fires onPress when tapped', () => {
25
+ const onPress = vi.fn()
26
+ render(<NavItem icon={icon} label="Live" onPress={onPress} />)
27
+ fireEvent.click(screen.getByRole('tab', { name: 'Live' }))
28
+ expect(onPress).toHaveBeenCalledTimes(1)
29
+ })
30
+
31
+ it('renders the live-elsewhere state', () => {
32
+ const { container } = render(<NavItem icon={icon} label="Live" live />)
33
+ expect(container.firstChild).toBeInTheDocument()
34
+ })
35
+ })
@@ -0,0 +1,75 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Pressable, View } from 'react-native'
3
+ import { cn } from '../../utils/cn'
4
+ import { Typography } from '../custom/Typography'
5
+
6
+ export interface NavItemProps {
7
+ /** The nav glyph (an icon from `components/icons`, rendered ~20px via `currentColor`). */
8
+ icon: ReactNode
9
+ /** Short label shown under the glyph (uppercased for display; used as the accessible name). */
10
+ label: string
11
+ /** Active category → left accent bar + brand-colored glyph & label. */
12
+ active?: boolean
13
+ /**
14
+ * A set is running for this category while it is NOT the active view
15
+ * (live-elsewhere) → a quiet muted-green label tint. Ignored when `active`.
16
+ */
17
+ live?: boolean
18
+ onPress?: () => void
19
+ className?: string
20
+ }
21
+
22
+ /**
23
+ * Shell S2 · NavItem — one category button in the {@link SideNav}: a 20px glyph
24
+ * over an uppercase micro-label in a 46×46 target. The button spans the full 60px
25
+ * rail so the active **left accent bar** sits flush to the rail's edge. States:
26
+ * active = accent bar + `brand-primary`; `live` (while not active) tints only the
27
+ * label `status-success-dark` (the glyph stays dim); otherwise dim `text-tertiary`.
28
+ */
29
+ export function NavItem({
30
+ icon,
31
+ label,
32
+ active = false,
33
+ live = false,
34
+ onPress,
35
+ className,
36
+ }: NavItemProps) {
37
+ const glyphColor = active ? 'text-brand-primary' : 'text-text-tertiary'
38
+ const labelColor = active
39
+ ? 'text-brand-primary'
40
+ : live
41
+ ? 'text-status-live-muted'
42
+ : 'text-text-tertiary'
43
+
44
+ return (
45
+ <Pressable
46
+ accessibilityRole="tab"
47
+ accessibilityState={{ selected: active }}
48
+ accessibilityLabel={label}
49
+ onPress={onPress}
50
+ className={cn('relative h-[46px] w-[60px] items-center justify-center', className)}
51
+ >
52
+ {active ? (
53
+ <View
54
+ testID="nav-item-accent"
55
+ className="absolute left-0 top-[13px] bottom-[13px] w-[3px] rounded-r-[3px] bg-brand-primary"
56
+ />
57
+ ) : null}
58
+ <View
59
+ className={cn(
60
+ 'h-[46px] w-[46px] items-center justify-center gap-[3px] rounded-[11px]',
61
+ glyphColor
62
+ )}
63
+ >
64
+ {icon}
65
+ <Typography
66
+ variant="button"
67
+ color="inherit"
68
+ className={cn('text-[8.5px] font-bold uppercase tracking-[0.4px]', labelColor)}
69
+ >
70
+ {label}
71
+ </Typography>
72
+ </View>
73
+ </Pressable>
74
+ )
75
+ }
@@ -0,0 +1,116 @@
1
+ # Shell chrome — component family
2
+
3
+ The persistent dashboard frame (top bar, and — as the decomposition proceeds — side nav, session
4
+ rail, drawers, pinned strip). Components live flat on disk; the tiering below is a documentation
5
+ contract, not a directory layout, so promoting/composing is pure import churn.
6
+
7
+ This README is the **index**: it maps each component's dependencies (**composes ↓**) and its consumers
8
+ (**used-by ↑**) so you can navigate the tree in both directions and spot where a hand-rolled element
9
+ should be reaching for an existing primitive instead. Storybook mirrors this tree under `Shell/TopBar/…`,
10
+ and every component's autodocs page repeats its "Composes" links.
11
+
12
+ ## Specimen: `SideNav` (the second shell unit, S2)
13
+
14
+ ```
15
+ SideNav ...................... organism — the 60px left rail (Shell/SideNav)
16
+ ├─ NavItem ................... molecule × 4 (Live · Review · Plan · Body)
17
+ │ ├─ icon ................... icons (shared primitive) — Activity/History/Layers/PersonStanding
18
+ │ └─ Typography ............. titan · button variant (uppercase micro-label)
19
+ └─ (accent bar) .............. bg-brand-primary edge bar on the active item
20
+ ```
21
+
22
+ Decisions locked 2026-07-08 (specimen `coordination/design-explorations/shell/S2-sidenav/`): lucide glyphs
23
+ (activity · history · layers · figure) · **Plan** label (narrower than "Program") · active = **left accent
24
+ bar** (short, centered on the icon+label) · **60px** icon+micro-label · live cue = **muted-green label**
25
+ (`status-live-muted`) on the Live item while a set runs off-Live · four items, no footer, no top-nav. Fixed
26
+ 60px at every width (labels sit under the glyph). `SideNav` is presentational — `activeKey` / `onNavigate` /
27
+ `liveKey`; the app owns routing + which key is live.
28
+
29
+ ## Specimen: `TopBar` (the first shell unit, S1)
30
+
31
+ ```
32
+ TopBar ....................... organism — the chrome band (Shell/TopBar)
33
+ ├─ BrandLockup ............... molecule
34
+ │ ├─ VoltrasMark ............ icons (shared primitive)
35
+ │ └─ Typography ............. titan · button variant
36
+ ├─ SessionStatePill .......... molecule (= the reusable StatusPill)
37
+ │ ├─ Indicator .............. titan · ping pulse + vivid color
38
+ │ └─ Typography ............. titan · monoLabel
39
+ ├─ Divider ................... titan · bg-border-prominent
40
+ ├─ DeviceMenu ................ organism
41
+ │ ├─ Popover ................ titan
42
+ │ ├─ DeviceIndicator ........ molecule → BluetoothIcon (icons primitive)
43
+ │ ├─ DeviceRow .............. molecule → Indicator + Typography (body2 / mono)
44
+ │ └─ Typography ............. titan · monoLabel (header)
45
+ ├─ DateTime .................. titan · mono variant, live 24h clock
46
+ └─ surfaceGradient.chrome .... theme · gradient primitive
47
+ ```
48
+
49
+ ## Dependency map
50
+
51
+ | Component | Tier | Composes ↓ | Used-by ↑ |
52
+ |---|---|---|---|
53
+ | `SideNav` | organism | NavItem ×4, `components/icons` (Activity/History/Layers/PersonStanding) | dashboard app root *(planned)* |
54
+ | `NavItem` | molecule | icon, Typography | SideNav |
55
+ | `TopBar` | organism | BrandLockup, SessionStatePill, Divider, DeviceMenu, DateTime, `surfaceGradient.chrome` | dashboard app root *(planned)* |
56
+ | `BrandLockup` | molecule | VoltrasMark, Typography | TopBar |
57
+ | `SessionStatePill` | molecule | Indicator, Typography | TopBar, **Live-view header** *(planned reuse)* |
58
+ | `DeviceMenu` | organism | Popover, DeviceIndicator, DeviceRow, Typography | TopBar |
59
+ | `DeviceIndicator` | molecule | BluetoothIcon | DeviceMenu |
60
+ | `DeviceRow` | molecule | Indicator, Typography | DeviceMenu |
61
+ | `VoltrasMark` / `BluetoothIcon` | **`components/icons`** primitive | `SvgIcon` base | BrandLockup / DeviceIndicator (+ any consumer) |
62
+
63
+ ## Shared substrates introduced here (reusable beyond the shell)
64
+
65
+ Building S1 grew the design system — these are now available to every component:
66
+
67
+ - **`components/icons`** — a shared icon primitive: an `SvgIcon` base (24×24, a11y contract, `currentColor`)
68
+ + `IconProps`. VoltrasMark / BluetoothIcon live here, and the previously-orphaned Workout icons
69
+ (Dumbbell / Star) were folded in (re-exported from `Workout/icons` for back-compat).
70
+ - **`theme/gradients.ts`** — `linearGradient(from, to, angle)` + named `surfaceGradient.*`, built on
71
+ `resolveColor` (themeable web CSS vars + native hex fallback).
72
+ - **`Indicator`** (titan atom) — `pulse: 'ping'` (expanding ring) + `success` / `error-vivid` colors.
73
+ - **`DateTime`** (titan) — `hour12`, `seconds`, self-ticking `live`, and Typography routing via `variant`.
74
+ - **`Typography`** — `mono` (technical readouts) + `monoLabel` (all-caps mono) variants.
75
+ - **Tokens** — a full **vivid** green/red palette + **`border-prominent`** divider token.
76
+
77
+ ## Reuse audit (the "are we failing to use existing components?" check)
78
+
79
+ Every leaf now composes a primitive rather than hand-rolling it:
80
+
81
+ | Concern | Uses | Not |
82
+ |---|---|---|
83
+ | status dots | `Indicator` | raw CSS/`View` dots |
84
+ | dividers | `Divider` (`border-prominent`) | hairline `View`s |
85
+ | mono / all-caps text | `Typography` `mono`/`monoLabel` | ad-hoc `font-mono` classes |
86
+ | glyphs | icon atoms (`VoltrasMark`, `BluetoothIcon`) | unicode chars / inline `<svg>` |
87
+ | chrome gradient | `surfaceGradient.chrome` | inline `linear-gradient` strings |
88
+ | colors | semantic tokens (vivid palette) | magic hex |
89
+
90
+ ## Testing
91
+
92
+ - **Unit** — every component + primitive has a `*.test.tsx` (render, behavior, a11y); shell branch coverage ≈96%.
93
+ - **Visual** — `tests/visual/stories.spec.ts` screenshots the `Shell/*` + `Icons` stories via Playwright
94
+ (`toHaveScreenshot`), clock-frozen + animations-disabled so the live-clock/animated stories are
95
+ deterministic. Widen `SCOPE` to cover more of the library. **Baselines must be generated in the pinned
96
+ container** (`mcr.microsoft.com/playwright:v1.58.2-noble`) — the `visual.yml` "Layer 2" step seeds them as
97
+ an artifact; commit the `*-chromium-linux.png` and flip the step to the `test:visual:stories` gate.
98
+ - **Lint guardrails** — components may not inline `linear-gradient` (use `surfaceGradient`); shell + icons may
99
+ not use raw hex (use tokens).
100
+
101
+ **S2 shared substrate:** four nav glyphs added to `components/icons` (`ActivityIcon`, `HistoryIcon`,
102
+ `LayersIcon`, `PersonStandingIcon` — lucide-mirrored, like Dumbbell/Star), available system-wide.
103
+
104
+ **Watch-list (known gaps to close as we go):**
105
+ - **Dot primitive overlap** — titan has both `StatusDot` (Workout, semantic) and `Indicator` (ui, generic).
106
+ The shell standardizes on `Indicator`; a future pass could consolidate.
107
+ - **Other hand-rolled gradients** — `MesoCard`, `DeviationBar`, `MesoStatusCard`, `BodyMapDetailPanel` still
108
+ inline `linear-gradient` strings; they should adopt `surfaceGradient` / `linearGradient`.
109
+ - **`status-live` token family (new, decoupled from success)** — introduced `status-live` (green-300, the
110
+ vivid LIVE-pill green) + `status-live-muted` (green-500 `#22A444`, the quiet nav cue) so "live" has its own
111
+ role: changing `status-success` no longer affects live, and vice-versa. Wired the full chain (semantic →
112
+ config → global.css → tailwind); `Indicator` gained a `live` color; the S1 LIVE pill was repointed
113
+ `success`→`live` (value-preserving). This realized a slice of TD-05.09 Fork 1b (wiring ramp steps as tokens).
114
+ - **`aria-selected` on `NavItem`** — RNW does not emit `aria-selected` from `accessibilityState={{selected}}`
115
+ in the jsdom test env, so active-state is asserted via the accent-bar testID. Confirm the on-device/RNW
116
+ build exposes selection to AT (may need an explicit `aria-selected` for full tab semantics).