@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
@@ -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/TopBar/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-popover--docs) + ' +
36
+ '[DeviceIndicator](?path=/docs/shell-topbar-devicemenu-deviceindicator--docs) (trigger) + ' +
37
+ '[DeviceRow](?path=/docs/shell-topbar-devicemenu-devicerow--docs) (list) + ' +
38
+ '[Typography](?path=/docs/custom-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/TopBar/DeviceMenu/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-indicator--docs) (status dot) + ' +
29
+ '[Typography](?path=/docs/custom-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,86 @@
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: `TopBar` (the first shell unit, S1)
13
+
14
+ ```
15
+ TopBar ....................... organism — the chrome band (Shell/TopBar)
16
+ ├─ BrandLockup ............... molecule
17
+ │ ├─ VoltrasMark ............ icons (shared primitive)
18
+ │ └─ Typography ............. titan · button variant
19
+ ├─ SessionStatePill .......... molecule (= the reusable StatusPill)
20
+ │ ├─ Indicator .............. titan · ping pulse + vivid color
21
+ │ └─ Typography ............. titan · monoLabel
22
+ ├─ Divider ................... titan · bg-border-prominent
23
+ ├─ DeviceMenu ................ organism
24
+ │ ├─ Popover ................ titan
25
+ │ ├─ DeviceIndicator ........ molecule → BluetoothIcon (icons primitive)
26
+ │ ├─ DeviceRow .............. molecule → Indicator + Typography (body2 / mono)
27
+ │ └─ Typography ............. titan · monoLabel (header)
28
+ ├─ DateTime .................. titan · mono variant, live 24h clock
29
+ └─ surfaceGradient.chrome .... theme · gradient primitive
30
+ ```
31
+
32
+ ## Dependency map
33
+
34
+ | Component | Tier | Composes ↓ | Used-by ↑ |
35
+ |---|---|---|---|
36
+ | `TopBar` | organism | BrandLockup, SessionStatePill, Divider, DeviceMenu, DateTime, `surfaceGradient.chrome` | dashboard app root *(planned)* |
37
+ | `BrandLockup` | molecule | VoltrasMark, Typography | TopBar |
38
+ | `SessionStatePill` | molecule | Indicator, Typography | TopBar, **Live-view header** *(planned reuse)* |
39
+ | `DeviceMenu` | organism | Popover, DeviceIndicator, DeviceRow, Typography | TopBar |
40
+ | `DeviceIndicator` | molecule | BluetoothIcon | DeviceMenu |
41
+ | `DeviceRow` | molecule | Indicator, Typography | DeviceMenu |
42
+ | `VoltrasMark` / `BluetoothIcon` | **`components/icons`** primitive | `SvgIcon` base | BrandLockup / DeviceIndicator (+ any consumer) |
43
+
44
+ ## Shared substrates introduced here (reusable beyond the shell)
45
+
46
+ Building S1 grew the design system — these are now available to every component:
47
+
48
+ - **`components/icons`** — a shared icon primitive: an `SvgIcon` base (24×24, a11y contract, `currentColor`)
49
+ + `IconProps`. VoltrasMark / BluetoothIcon live here, and the previously-orphaned Workout icons
50
+ (Dumbbell / Star) were folded in (re-exported from `Workout/icons` for back-compat).
51
+ - **`theme/gradients.ts`** — `linearGradient(from, to, angle)` + named `surfaceGradient.*`, built on
52
+ `resolveColor` (themeable web CSS vars + native hex fallback).
53
+ - **`Indicator`** (titan atom) — `pulse: 'ping'` (expanding ring) + `success` / `error-vivid` colors.
54
+ - **`DateTime`** (titan) — `hour12`, `seconds`, self-ticking `live`, and Typography routing via `variant`.
55
+ - **`Typography`** — `mono` (technical readouts) + `monoLabel` (all-caps mono) variants.
56
+ - **Tokens** — a full **vivid** green/red palette + **`border-prominent`** divider token.
57
+
58
+ ## Reuse audit (the "are we failing to use existing components?" check)
59
+
60
+ Every leaf now composes a primitive rather than hand-rolling it:
61
+
62
+ | Concern | Uses | Not |
63
+ |---|---|---|
64
+ | status dots | `Indicator` | raw CSS/`View` dots |
65
+ | dividers | `Divider` (`border-prominent`) | hairline `View`s |
66
+ | mono / all-caps text | `Typography` `mono`/`monoLabel` | ad-hoc `font-mono` classes |
67
+ | glyphs | icon atoms (`VoltrasMark`, `BluetoothIcon`) | unicode chars / inline `<svg>` |
68
+ | chrome gradient | `surfaceGradient.chrome` | inline `linear-gradient` strings |
69
+ | colors | semantic tokens (vivid palette) | magic hex |
70
+
71
+ ## Testing
72
+
73
+ - **Unit** — every component + primitive has a `*.test.tsx` (render, behavior, a11y); shell branch coverage ≈96%.
74
+ - **Visual** — `tests/visual/stories.spec.ts` screenshots the `Shell/*` + `Icons` stories via Playwright
75
+ (`toHaveScreenshot`), clock-frozen + animations-disabled so the live-clock/animated stories are
76
+ deterministic. Widen `SCOPE` to cover more of the library. **Baselines must be generated in the pinned
77
+ container** (`mcr.microsoft.com/playwright:v1.58.2-noble`) — the `visual.yml` "Layer 2" step seeds them as
78
+ an artifact; commit the `*-chromium-linux.png` and flip the step to the `test:visual:stories` gate.
79
+ - **Lint guardrails** — components may not inline `linear-gradient` (use `surfaceGradient`); shell + icons may
80
+ not use raw hex (use tokens).
81
+
82
+ **Watch-list (known gaps to close as we go):**
83
+ - **Dot primitive overlap** — titan has both `StatusDot` (Workout, semantic) and `Indicator` (ui, generic).
84
+ The shell standardizes on `Indicator`; a future pass could consolidate.
85
+ - **Other hand-rolled gradients** — `MesoCard`, `DeviationBar`, `MesoStatusCard`, `BodyMapDetailPanel` still
86
+ inline `linear-gradient` strings; they should adopt `surfaceGradient` / `linearGradient`.
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { SessionStatePill } from './SessionStatePill'
3
+
4
+ const meta: Meta<typeof SessionStatePill> = {
5
+ title: 'Shell/TopBar/SessionStatePill',
6
+ component: SessionStatePill,
7
+ tags: ['autodocs'],
8
+ args: { state: 'live' },
9
+ argTypes: {
10
+ state: { control: 'select', options: ['live', 'rest', 'idle'] },
11
+ label: { control: 'text' },
12
+ },
13
+ parameters: {
14
+ docs: {
15
+ description: {
16
+ component:
17
+ '**Molecule** (= the ledger’s reusable StatusPill — also used by the Live-view header). Composes ' +
18
+ '[Indicator](?path=/docs/components-indicator--docs) (pulse `ping` + vivid color for live) + ' +
19
+ '[Typography](?path=/docs/custom-typography--docs) (`monoLabel`). Use the `state` control to switch.',
20
+ },
21
+ },
22
+ },
23
+ }
24
+ export default meta
25
+ type Story = StoryObj<typeof SessionStatePill>
26
+
27
+ export const Default: Story = {}
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { SessionStatePill } from './SessionStatePill'
5
+
6
+ describe('SessionStatePill', () => {
7
+ it('labels each state', () => {
8
+ const { rerender } = render(<SessionStatePill state="live" />)
9
+ expect(screen.getByText('LIVE')).toBeInTheDocument()
10
+ rerender(<SessionStatePill state="rest" />)
11
+ expect(screen.getByText('REST')).toBeInTheDocument()
12
+ rerender(<SessionStatePill state="idle" />)
13
+ expect(screen.getByText('IDLE')).toBeInTheDocument()
14
+ })
15
+
16
+ it('supports a custom label', () => {
17
+ render(<SessionStatePill state="live" label="SET LIVE" />)
18
+ expect(screen.getByText('SET LIVE')).toBeInTheDocument()
19
+ })
20
+
21
+ it('has no accessibility violations', async () => {
22
+ const { container } = render(<SessionStatePill state="live" />)
23
+ expect(await axe(container)).toHaveNoViolations()
24
+ })
25
+ })
@@ -0,0 +1,46 @@
1
+ import { View, type ViewProps } from 'react-native'
2
+ import { cn } from '../../utils/cn'
3
+ import { Indicator, type IndicatorColor, type IndicatorPulse } from '../ui/indicator'
4
+ import { Typography, type TypographyColor } from '../custom/Typography'
5
+
6
+ export type SessionState = 'live' | 'rest' | 'idle'
7
+
8
+ export interface SessionStatePillProps extends ViewProps {
9
+ /** Session state → dot color + pulse + label. */
10
+ state: SessionState
11
+ /** Override the default label (LIVE / REST / IDLE). */
12
+ label?: string
13
+ className?: string
14
+ }
15
+
16
+ const stateConfig: Record<
17
+ SessionState,
18
+ {
19
+ label: string
20
+ color: IndicatorColor
21
+ pulse: boolean | IndicatorPulse
22
+ textColor: TypographyColor
23
+ }
24
+ > = {
25
+ // live = vivid green with an expanding ring; rest = solid amber (no pulse — operator); idle is dim.
26
+ live: { label: 'LIVE', color: 'success', pulse: 'ping', textColor: 'primary' },
27
+ rest: { label: 'REST', color: 'warning', pulse: false, textColor: 'primary' },
28
+ idle: { label: 'IDLE', color: 'default', pulse: false, textColor: 'secondary' },
29
+ }
30
+
31
+ /**
32
+ * The global session-state readout (dot + label). Shared: the top bar's status
33
+ * and the Live-view header both use it (this is the ledger's "StatusPill",
34
+ * Family C — built once). S1 · SessionStatePill.
35
+ */
36
+ export function SessionStatePill({ state, label, className, ...props }: SessionStatePillProps) {
37
+ const cfg = stateConfig[state]
38
+ return (
39
+ <View className={cn('flex-row items-center gap-2', className)} {...props}>
40
+ <Indicator size="md" color={cfg.color} pulse={cfg.pulse} />
41
+ <Typography variant="monoLabel" color={cfg.textColor} className="text-[11px]">
42
+ {label ?? cfg.label}
43
+ </Typography>
44
+ </View>
45
+ )
46
+ }
@@ -0,0 +1,49 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { TopBar } from './TopBar'
3
+ import { type Device } from './DeviceRow'
4
+
5
+ const DEVICES: Device[] = [
6
+ { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
7
+ { id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
8
+ { id: 'Voltra-77E0', nickname: 'Spare', slot: null, state: 'available' },
9
+ ]
10
+
11
+ const meta: Meta<typeof TopBar> = {
12
+ title: 'Shell/TopBar',
13
+ component: TopBar,
14
+ tags: ['autodocs'],
15
+ args: { state: 'live', devices: DEVICES },
16
+ argTypes: {
17
+ state: { control: 'select', options: ['live', 'rest', 'idle'] },
18
+ devices: { control: 'object' },
19
+ subtitle: { control: 'text' },
20
+ showSubtitle: { control: 'boolean' },
21
+ showClock: { control: 'boolean' },
22
+ time: { control: false },
23
+ onSelectDevice: { control: false },
24
+ },
25
+ parameters: {
26
+ layout: 'fullscreen',
27
+ docs: {
28
+ description: {
29
+ component:
30
+ '**Organism** — the persistent shell chrome band, and the root of the top-bar family ' +
31
+ '(see `shell/README.md` for the full dependency map). Composes ' +
32
+ '[BrandLockup](?path=/docs/shell-topbar-brandlockup--docs) + ' +
33
+ '[SessionStatePill](?path=/docs/shell-topbar-sessionstatepill--docs) + ' +
34
+ '[Divider](?path=/docs/components-divider--docs) (`bg-border-prominent`) + ' +
35
+ '[DeviceMenu](?path=/docs/shell-topbar-devicemenu--docs) + ' +
36
+ '[DateTime](?path=/docs/custom-datetime--docs) (`variant="mono"` live clock). ' +
37
+ 'Background = the shared `surfaceGradient.chrome` primitive.\n\n' +
38
+ '**Try it:** use the **Controls** to change `state`, edit `devices` (set one to `lost` to see the ' +
39
+ 'fault), or toggle `showSubtitle` / `showClock`. **Resize the canvas** to watch the ' +
40
+ 'container-responsive collapse (SIZE-D01) — subtitle drops < 1024px, clock < 720px. ' +
41
+ 'Click the device glyph to open the menu.',
42
+ },
43
+ },
44
+ },
45
+ }
46
+ export default meta
47
+ type Story = StoryObj<typeof TopBar>
48
+
49
+ export const Default: Story = {}
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { TopBar } from './TopBar'
5
+ import { type Device } from './DeviceRow'
6
+
7
+ const devices: Device[] = [
8
+ { id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
9
+ { id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
10
+ ]
11
+
12
+ const AT_1612 = new Date(2024, 0, 1, 16, 12) // local 16:12
13
+
14
+ describe('TopBar', () => {
15
+ it('renders brand, state and a 24h clock', () => {
16
+ render(<TopBar state="live" devices={devices} time={AT_1612} showClock />)
17
+ expect(screen.getByText('VOLTRAS')).toBeInTheDocument()
18
+ expect(screen.getByText('LIVE')).toBeInTheDocument()
19
+ expect(screen.getByText('16:12')).toBeInTheDocument()
20
+ })
21
+
22
+ it('hides the clock when showClock is false', () => {
23
+ render(<TopBar state="live" devices={devices} time={AT_1612} showClock={false} />)
24
+ expect(screen.queryByText('16:12')).not.toBeInTheDocument()
25
+ })
26
+
27
+ it('hides the subtitle when showSubtitle is false', () => {
28
+ render(<TopBar state="rest" devices={devices} showSubtitle={false} />)
29
+ expect(screen.queryByText('/ wall dashboard')).not.toBeInTheDocument()
30
+ })
31
+
32
+ it('has no accessibility violations', async () => {
33
+ const { container } = render(<TopBar state="live" devices={devices} time={AT_1612} showClock />)
34
+ expect(await axe(container)).toHaveNoViolations()
35
+ })
36
+ })
@@ -0,0 +1,89 @@
1
+ import { useState } from 'react'
2
+ import { View, type LayoutChangeEvent } from 'react-native'
3
+ import { cn } from '../../utils/cn'
4
+ import { surfaceGradient } from '../../theme/gradients'
5
+ import { Divider } from '../ui/divider'
6
+ import { DateTime } from '../custom/DateTime'
7
+ import { BrandLockup } from './BrandLockup'
8
+ import { SessionStatePill, type SessionState } from './SessionStatePill'
9
+ import { DeviceMenu } from './DeviceMenu'
10
+ import { type Device } from './DeviceRow'
11
+
12
+ export interface TopBarProps {
13
+ /** Global session state → the status pill. */
14
+ state: SessionState
15
+ /** Devices for the connection glyph + dropdown. */
16
+ devices: Device[]
17
+ /** The moment to display in the clock (Date/timestamp). Omit for a live ticking clock. */
18
+ time?: number | Date
19
+ /** Brand subtitle. Default "wall dashboard". */
20
+ subtitle?: string
21
+ /** Force the subtitle on/off; defaults to container-responsive (hidden below ~1024px). */
22
+ showSubtitle?: boolean
23
+ /** Force the clock on/off; defaults to container-responsive (hidden below ~720px). */
24
+ showClock?: boolean
25
+ onSelectDevice?: (device: Device) => void
26
+ className?: string
27
+ }
28
+
29
+ // SIZE-D01: responsiveness is container-driven (measured width), not a fixed size prop.
30
+ const SUBTITLE_MIN = 1024
31
+ const CLOCK_MIN = 720
32
+
33
+ /**
34
+ * S1 · TopBar — the persistent shell chrome band. Brand (left) + a right cluster
35
+ * of session state · device glyph · clock (clock edge-pinned so spacing stays
36
+ * stable as the state text changes). Collapses subtitle then clock as its
37
+ * container narrows.
38
+ */
39
+ export function TopBar({
40
+ state,
41
+ devices,
42
+ time,
43
+ subtitle = 'wall dashboard',
44
+ showSubtitle,
45
+ showClock,
46
+ onSelectDevice,
47
+ className,
48
+ }: TopBarProps) {
49
+ const [width, setWidth] = useState(SUBTITLE_MIN)
50
+ const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)
51
+
52
+ const subtitleVisible = showSubtitle ?? width >= SUBTITLE_MIN
53
+ const clockVisible = showClock ?? width >= CLOCK_MIN
54
+
55
+ return (
56
+ <View
57
+ onLayout={onLayout}
58
+ // shared chrome gradient (web); solid bg-surface-elevated is the native fallback
59
+ style={surfaceGradient.chrome() as object}
60
+ className={cn(
61
+ 'h-[46px] flex-row items-center gap-[14px] px-4 bg-surface-elevated border-b border-border',
62
+ className
63
+ )}
64
+ >
65
+ <BrandLockup subtitle={subtitle} showSubtitle={subtitleVisible} />
66
+
67
+ {/* right cluster — order: state · device · time (time pinned to the edge) */}
68
+ <View className="ml-auto flex-row items-center gap-[12px]">
69
+ <SessionStatePill state={state} />
70
+ <Divider orientation="vertical" className="h-4 bg-border-prominent" />
71
+ <DeviceMenu devices={devices} onSelectDevice={onSelectDevice} />
72
+ {clockVisible ? (
73
+ <>
74
+ <Divider orientation="vertical" className="h-4 bg-border-prominent" />
75
+ <DateTime
76
+ value={time}
77
+ live={time == null}
78
+ format="time"
79
+ hour12={false}
80
+ variant="mono"
81
+ color="secondary"
82
+ className="text-[11px] min-w-[38px] text-right"
83
+ />
84
+ </>
85
+ ) : null}
86
+ </View>
87
+ </View>
88
+ )
89
+ }
@@ -0,0 +1,8 @@
1
+ // Shell chrome — persistent dashboard frame (nav / top bar / rail / drawers / strip).
2
+ // S1 · Top bar family. (Icons live in the shared `components/icons` primitive.)
3
+ export * from './BrandLockup'
4
+ export * from './SessionStatePill'
5
+ export * from './DeviceIndicator'
6
+ export * from './DeviceRow'
7
+ export * from './DeviceMenu'
8
+ export * from './TopBar'