@titan-design/react-ui 0.5.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.
- package/dist/bodymap.js +232 -185
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +232 -185
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +164 -8
- package/dist/index.d.ts +164 -8
- package/dist/index.js +835 -342
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +816 -343
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +570 -522
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +570 -522
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +461 -199
- package/dist/theme/index.d.ts +461 -199
- package/dist/theme/index.js +311 -97
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +303 -98
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +148 -139
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +148 -139
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +3 -1
- package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
- package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
- package/src/components/custom/DateTime/DateTime.tsx +55 -15
- package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
- package/src/components/custom/Gauge/Gauge.tsx +6 -3
- package/src/components/custom/Scatter/Scatter.tsx +3 -5
- package/src/components/custom/Treemap/Treemap.tsx +3 -5
- package/src/components/custom/Typography/Typography.stories.tsx +45 -27
- package/src/components/custom/Typography/Typography.test.tsx +13 -3
- package/src/components/custom/Typography/Typography.tsx +5 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
- package/src/components/custom/Workout/BodyMap.tsx +6 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
- package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
- package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.tsx +7 -4
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +2 -1
- package/src/components/custom/Workout/InputBar.tsx +2 -1
- package/src/components/custom/Workout/IntensityBar.test.tsx +8 -8
- package/src/components/custom/Workout/IntensityBar.tsx +10 -6
- package/src/components/custom/Workout/MesoCard.tsx +8 -3
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
- package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
- package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
- package/src/components/custom/Workout/PrBadge.tsx +5 -2
- package/src/components/custom/Workout/PrHistoryModal.tsx +4 -3
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
- package/src/components/custom/Workout/RestTimer.tsx +2 -1
- package/src/components/custom/Workout/SetRow.tsx +2 -1
- package/src/components/custom/Workout/Sparkline.tsx +2 -1
- package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
- package/src/components/custom/Workout/StatusDot.tsx +12 -9
- package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
- package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +6 -3
- package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
- package/src/components/custom/Workout/WeekRow.tsx +3 -2
- package/src/components/custom/Workout/WeightBadge.tsx +6 -2
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
- package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
- package/src/components/custom/Workout/icons.tsx +6 -67
- package/src/components/icons/SvgIcon.tsx +50 -0
- package/src/components/icons/icons.stories.tsx +57 -0
- package/src/components/icons/icons.test.tsx +43 -0
- package/src/components/icons/icons.tsx +45 -0
- package/src/components/icons/index.ts +4 -0
- package/src/components/index.ts +6 -0
- package/src/components/shell/BrandLockup.stories.tsx +27 -0
- package/src/components/shell/BrandLockup.test.tsx +23 -0
- package/src/components/shell/BrandLockup.tsx +47 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
- package/src/components/shell/DeviceIndicator.test.tsx +26 -0
- package/src/components/shell/DeviceIndicator.tsx +52 -0
- package/src/components/shell/DeviceMenu.stories.tsx +47 -0
- package/src/components/shell/DeviceMenu.test.tsx +40 -0
- package/src/components/shell/DeviceMenu.tsx +65 -0
- package/src/components/shell/DeviceRow.stories.tsx +38 -0
- package/src/components/shell/DeviceRow.test.tsx +37 -0
- package/src/components/shell/DeviceRow.tsx +58 -0
- package/src/components/shell/README.md +86 -0
- package/src/components/shell/SessionStatePill.stories.tsx +27 -0
- package/src/components/shell/SessionStatePill.test.tsx +25 -0
- package/src/components/shell/SessionStatePill.tsx +46 -0
- package/src/components/shell/TopBar.stories.tsx +49 -0
- package/src/components/shell/TopBar.test.tsx +36 -0
- package/src/components/shell/TopBar.tsx +89 -0
- package/src/components/shell/index.ts +8 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
- package/src/components/ui/indicator/Indicator.test.tsx +19 -1
- package/src/components/ui/indicator/Indicator.tsx +45 -6
- package/src/components/ui/indicator/index.ts +1 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -2
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
- package/src/theme/ColorSystem.stories.tsx +323 -0
- package/src/theme/Colors.stories.tsx +69 -1
- package/src/theme/barrel.ts +1 -0
- package/src/theme/config.ts +16 -3
- package/src/theme/extracted-colors-dataviz.ts +21 -0
- package/src/theme/extracted-colors-ui.ts +20 -0
- package/src/theme/global.css +64 -52
- package/src/theme/gradients.test.ts +25 -0
- package/src/theme/gradients.ts +36 -0
- package/src/theme/tokens/primitives.test.ts +232 -0
- package/src/theme/tokens/primitives.ts +247 -0
- package/src/theme/tokens/semantic.ts +93 -81
- package/src/theme/workout-tokens.ts +22 -21
- package/src/utils/avatar-color.ts +3 -3
- package/tailwind.config.js +10 -3
|
@@ -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'
|
|
@@ -8,7 +8,10 @@ const meta: Meta<typeof Indicator> = {
|
|
|
8
8
|
tags: ['autodocs'],
|
|
9
9
|
argTypes: {
|
|
10
10
|
size: { control: 'select', options: ['xs', 'sm', 'md', 'lg'] },
|
|
11
|
-
color: {
|
|
11
|
+
color: {
|
|
12
|
+
control: 'select',
|
|
13
|
+
options: ['default', 'primary', 'success', 'error', 'warning', 'info'],
|
|
14
|
+
},
|
|
12
15
|
glow: { control: 'boolean' },
|
|
13
16
|
ring: { control: 'boolean' },
|
|
14
17
|
},
|
|
@@ -72,3 +75,36 @@ export const CustomColor: Story = {
|
|
|
72
75
|
</View>
|
|
73
76
|
),
|
|
74
77
|
}
|
|
78
|
+
|
|
79
|
+
export const Pulse: Story = {
|
|
80
|
+
render: () => (
|
|
81
|
+
<View className="flex-row gap-6 items-center">
|
|
82
|
+
<Indicator size="md" color="success" pulse />
|
|
83
|
+
<Indicator size="md" color="success" pulse="ping" />
|
|
84
|
+
<Indicator size="md" color="warning" />
|
|
85
|
+
<Indicator size="md" color="default" />
|
|
86
|
+
</View>
|
|
87
|
+
),
|
|
88
|
+
parameters: {
|
|
89
|
+
docs: {
|
|
90
|
+
description: {
|
|
91
|
+
story:
|
|
92
|
+
'Opacity pulse (default), expanding-ring pulse (`pulse="ping"`, green for live), solid, dim.',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const Vivid: Story = {
|
|
99
|
+
render: () => (
|
|
100
|
+
<View className="flex-row gap-4 items-center">
|
|
101
|
+
<Indicator size="md" color="success" />
|
|
102
|
+
<Indicator size="md" color="success" />
|
|
103
|
+
<Indicator size="md" color="error" />
|
|
104
|
+
<Indicator size="md" color="error-vivid" />
|
|
105
|
+
</View>
|
|
106
|
+
),
|
|
107
|
+
parameters: {
|
|
108
|
+
docs: { description: { story: 'Standard status vs the vivid palette (success/error).' } },
|
|
109
|
+
},
|
|
110
|
+
}
|
|
@@ -18,7 +18,15 @@ describe('Indicator', () => {
|
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
it('renders with all color options', () => {
|
|
21
|
-
const colors = [
|
|
21
|
+
const colors = [
|
|
22
|
+
'default',
|
|
23
|
+
'primary',
|
|
24
|
+
'success',
|
|
25
|
+
'error',
|
|
26
|
+
'warning',
|
|
27
|
+
'info',
|
|
28
|
+
'error-vivid',
|
|
29
|
+
] as const
|
|
22
30
|
colors.forEach((color) => {
|
|
23
31
|
const { unmount } = render(<Indicator color={color} />)
|
|
24
32
|
unmount()
|
|
@@ -45,6 +53,16 @@ describe('Indicator', () => {
|
|
|
45
53
|
expect(container.firstChild).toBeInTheDocument()
|
|
46
54
|
})
|
|
47
55
|
|
|
56
|
+
it('supports pulse prop (opacity)', () => {
|
|
57
|
+
const { container } = render(<Indicator pulse color="success" />)
|
|
58
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('supports the ping pulse variant', () => {
|
|
62
|
+
const { container } = render(<Indicator pulse="ping" color="success" />)
|
|
63
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
48
66
|
describe('accessibility', () => {
|
|
49
67
|
it('has no accessibility violations', async () => {
|
|
50
68
|
const { container } = render(<Indicator color="success" />)
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
1
|
import { View, type ViewProps } from 'react-native'
|
|
3
2
|
import { cn } from '../../../utils/cn'
|
|
4
3
|
|
|
5
4
|
export type IndicatorSize = 'xs' | 'sm' | 'md' | 'lg'
|
|
6
|
-
export type IndicatorColor =
|
|
5
|
+
export type IndicatorColor =
|
|
6
|
+
| 'default'
|
|
7
|
+
| 'primary'
|
|
8
|
+
| 'success'
|
|
9
|
+
| 'error'
|
|
10
|
+
| 'warning'
|
|
11
|
+
| 'info'
|
|
12
|
+
| 'error-vivid'
|
|
13
|
+
|
|
14
|
+
/** How a pulsing indicator animates: a subtle opacity fade, or an expanding ring. */
|
|
15
|
+
export type IndicatorPulse = 'opacity' | 'ping'
|
|
7
16
|
|
|
8
17
|
export interface IndicatorProps extends ViewProps {
|
|
9
18
|
/** Dot size */
|
|
@@ -16,6 +25,8 @@ export interface IndicatorProps extends ViewProps {
|
|
|
16
25
|
glow?: boolean
|
|
17
26
|
/** Add a ring border */
|
|
18
27
|
ring?: boolean
|
|
28
|
+
/** Animate for live/active status. `true` = opacity fade; `'ping'` = expanding ring. */
|
|
29
|
+
pulse?: boolean | IndicatorPulse
|
|
19
30
|
/** Additional className */
|
|
20
31
|
className?: string
|
|
21
32
|
}
|
|
@@ -34,6 +45,7 @@ const colorStyles: Record<IndicatorColor, string> = {
|
|
|
34
45
|
error: 'bg-status-error',
|
|
35
46
|
warning: 'bg-status-warning',
|
|
36
47
|
info: 'bg-status-info',
|
|
48
|
+
'error-vivid': 'bg-status-error-vivid',
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
export function Indicator({
|
|
@@ -42,14 +54,39 @@ export function Indicator({
|
|
|
42
54
|
customColor,
|
|
43
55
|
glow = false,
|
|
44
56
|
ring = false,
|
|
57
|
+
pulse = false,
|
|
45
58
|
className,
|
|
46
59
|
style,
|
|
47
60
|
...props
|
|
48
61
|
}: IndicatorProps) {
|
|
62
|
+
const pulseMode: IndicatorPulse | false = pulse === true ? 'opacity' : pulse || false
|
|
63
|
+
const colorClass = !customColor ? colorStyles[color] : undefined
|
|
64
|
+
const colorStyle = customColor ? { backgroundColor: customColor } : undefined
|
|
65
|
+
|
|
66
|
+
// Expanding-ring pulse: a ping layer behind the solid dot (for live/active status).
|
|
67
|
+
if (pulseMode === 'ping') {
|
|
68
|
+
return (
|
|
69
|
+
<View className={cn('relative', sizeStyles[size], className)} style={style} {...props}>
|
|
70
|
+
<View
|
|
71
|
+
className={cn('absolute inset-0 rounded-full animate-ping', colorClass)}
|
|
72
|
+
style={colorStyle}
|
|
73
|
+
/>
|
|
74
|
+
<View
|
|
75
|
+
className={cn(
|
|
76
|
+
'relative rounded-full',
|
|
77
|
+
sizeStyles[size],
|
|
78
|
+
colorClass,
|
|
79
|
+
ring && 'border-2 border-background-base'
|
|
80
|
+
)}
|
|
81
|
+
style={colorStyle}
|
|
82
|
+
/>
|
|
83
|
+
</View>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
49
87
|
const dynamicStyle = {
|
|
50
88
|
...(typeof style === 'object' ? style : {}),
|
|
51
|
-
...(
|
|
52
|
-
...(glow && !customColor ? {} : {}),
|
|
89
|
+
...(colorStyle ?? {}),
|
|
53
90
|
...(glow && customColor ? { boxShadow: `0 0 6px ${customColor}` } : {}),
|
|
54
91
|
}
|
|
55
92
|
|
|
@@ -58,12 +95,14 @@ export function Indicator({
|
|
|
58
95
|
className={cn(
|
|
59
96
|
'rounded-full shrink-0',
|
|
60
97
|
sizeStyles[size],
|
|
61
|
-
|
|
98
|
+
colorClass,
|
|
99
|
+
pulseMode === 'opacity' && 'animate-pulse',
|
|
62
100
|
ring && 'border-2 border-background-base',
|
|
63
|
-
glow && !customColor && color === 'success' && 'shadow-[0_0_6px_rgba(
|
|
101
|
+
glow && !customColor && color === 'success' && 'shadow-[0_0_6px_rgba(46,213,115,0.6)]',
|
|
64
102
|
glow && !customColor && color === 'error' && 'shadow-[0_0_6px_rgba(239,68,68,0.6)]',
|
|
65
103
|
glow && !customColor && color === 'warning' && 'shadow-[0_0_6px_rgba(245,158,11,0.6)]',
|
|
66
104
|
glow && !customColor && color === 'primary' && 'shadow-[0_0_6px_rgba(255,121,0,0.6)]',
|
|
105
|
+
glow && !customColor && color === 'error-vivid' && 'shadow-[0_0_6px_rgba(255,71,87,0.6)]',
|
|
67
106
|
className
|
|
68
107
|
)}
|
|
69
108
|
style={Object.keys(dynamicStyle).length > 0 ? dynamicStyle : style}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Indicator } from './Indicator'
|
|
2
|
-
export type { IndicatorProps, IndicatorSize, IndicatorColor } from './Indicator'
|
|
2
|
+
export type { IndicatorProps, IndicatorSize, IndicatorColor, IndicatorPulse } from './Indicator'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { ActivityIndicator, View, type ViewProps } from 'react-native'
|
|
3
3
|
import { cn } from '../../../utils/cn'
|
|
4
|
+
import { SPINNER_PRIMARY, SPINNER_SECONDARY } from '../../../theme/extracted-colors-ui'
|
|
4
5
|
|
|
5
6
|
export type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
6
7
|
export type SpinnerColor = 'primary' | 'secondary' | 'white' | 'default'
|
|
@@ -24,8 +25,8 @@ const sizeMap: Record<SpinnerSize, 'small' | 'large'> = {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const colorMap: Record<SpinnerColor, string> = {
|
|
27
|
-
primary:
|
|
28
|
-
secondary:
|
|
28
|
+
primary: SPINNER_PRIMARY,
|
|
29
|
+
secondary: SPINNER_SECONDARY,
|
|
29
30
|
white: '#FFFFFF',
|
|
30
31
|
default: '#6B7280',
|
|
31
32
|
}
|
|
@@ -2,6 +2,7 @@ import React, { useState, useCallback, createContext, useContext } from 'react'
|
|
|
2
2
|
import { View, Text, Pressable, type ViewProps, StyleSheet, Platform } from 'react-native'
|
|
3
3
|
import { cn } from '../../../utils/cn'
|
|
4
4
|
import { neumorphicShadows, getHoverColors } from '../../../theme'
|
|
5
|
+
import { resolveColor } from '../../../theme/resolve-color'
|
|
5
6
|
|
|
6
7
|
export type ToolbarButtonVariant = 'default' | 'neumorphic'
|
|
7
8
|
export type ToolbarButtonSize = 'sm' | 'md' | 'lg'
|
|
@@ -129,7 +130,7 @@ export function ToolbarButton({
|
|
|
129
130
|
|
|
130
131
|
// Determine icon color based on state
|
|
131
132
|
const getIconColor = () => {
|
|
132
|
-
if (isActive === true) return '
|
|
133
|
+
if (isActive === true) return resolveColor('brand-primary')
|
|
133
134
|
return showActive ? '#FFFFFF' : '#D1D1D1'
|
|
134
135
|
}
|
|
135
136
|
|