@titan-design/react-ui 0.1.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/docs/WEB_SETUP.md +217 -0
  16. package/package.json +14 -2
  17. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  18. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  19. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  20. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  21. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  22. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  23. package/src/components/custom/Workout/index.ts +2 -0
  24. package/src/components/custom/index.ts +1 -0
  25. package/src/components/custom/stepper/index.ts +2 -2
  26. package/src/components/ui/avatar/Avatar.test.tsx +17 -0
  27. package/src/components/ui/avatar/Avatar.tsx +13 -5
  28. package/src/components/ui/badge/Badge.test.tsx +17 -0
  29. package/src/components/ui/badge/Badge.tsx +14 -0
  30. package/src/components/ui/badge/index.ts +1 -0
  31. package/src/components/ui/button/Button.tsx +55 -0
  32. package/src/components/ui/card/Card.test.tsx +29 -0
  33. package/src/components/ui/card/Card.tsx +32 -11
  34. package/src/components/ui/index.ts +2 -0
  35. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  36. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  37. package/src/components/ui/indicator/Indicator.tsx +73 -0
  38. package/src/components/ui/indicator/index.ts +2 -0
  39. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  40. package/src/components/ui/pill/Pill.test.tsx +69 -0
  41. package/src/components/ui/pill/Pill.tsx +104 -0
  42. package/src/components/ui/pill/index.ts +2 -0
  43. package/src/components/ui/popover/Popover.test.tsx +144 -2
  44. package/src/components/ui/popover/Popover.tsx +81 -6
  45. package/src/components/ui/progress/Progress.test.tsx +29 -0
  46. package/src/components/ui/progress/Progress.tsx +54 -35
  47. package/src/components/ui/select/Select.test.tsx +20 -0
  48. package/src/components/ui/select/Select.tsx +9 -2
  49. package/src/components/ui/toast/index.ts +1 -0
  50. package/src/components/ui/tooltip/Tooltip.test.tsx +83 -0
  51. package/src/components/ui/tooltip/Tooltip.tsx +117 -26
  52. package/src/stories/PresetShowcase.stories.tsx +462 -0
  53. package/src/stories/ThemePresets.stories.tsx +85 -0
  54. package/src/theme/global.css +129 -1
  55. package/src/theme/index.ts +1 -0
  56. package/src/theme/presets/apply.ts +97 -0
  57. package/src/theme/presets/audiobook.ts +93 -0
  58. package/src/theme/presets/default.ts +6 -0
  59. package/src/theme/presets/index.ts +4 -0
  60. package/src/theme/presets/presets.test.ts +51 -0
  61. package/src/theme/presets/types.ts +76 -0
  62. package/src/utils/avatar-color.test.ts +30 -0
  63. package/src/utils/avatar-color.ts +20 -0
  64. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  65. package/src/web-jsx/jsx-runtime.ts +41 -0
  66. package/tailwind.config.js +53 -3
  67. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,217 @@
1
+ # Web Consumer Setup
2
+
3
+ How to use `@titan-design/react-ui` in a Vite-based web application.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Vite** 5+ with `@vitejs/plugin-react`
8
+ - **Tailwind CSS** 3.4+
9
+ - **react-native-web** 0.19+
10
+
11
+ ## Quick Start
12
+
13
+ ### 1. Install dependencies
14
+
15
+ ```bash
16
+ npm install @titan-design/react-ui react-native-web
17
+ npm install -D tailwindcss autoprefixer postcss nativewind
18
+ ```
19
+
20
+ > `nativewind` is an **optional peer dependency** and only needed at build time — it
21
+ > provides a Tailwind preset that adds React Native platform variants (`web:`,
22
+ > `native:`) used by titan components. It is NOT needed at runtime on web.
23
+ > Install it as a dev dependency so Tailwind can resolve the preset.
24
+
25
+ ### 2. Configure Vite
26
+
27
+ ```ts
28
+ // vite.config.ts
29
+ import { defineConfig } from 'vite'
30
+ import react from '@vitejs/plugin-react'
31
+
32
+ export default defineConfig({
33
+ plugins: [react()],
34
+ resolve: {
35
+ alias: {
36
+ 'react-native': 'react-native-web',
37
+ },
38
+ },
39
+ })
40
+ ```
41
+
42
+ That's it. No `jsxImportSource`, no `optimizeDeps`, no `resolve.dedupe`.
43
+
44
+ ### 3. Configure Tailwind CSS
45
+
46
+ ```js
47
+ // tailwind.config.js
48
+ const titanConfig = require('@titan-design/react-ui/tailwind.config.js')
49
+
50
+ /** @type {import('tailwindcss').Config} */
51
+ module.exports = {
52
+ content: [
53
+ './src/**/*.{js,jsx,ts,tsx}',
54
+ './node_modules/@titan-design/react-ui/dist/**/*.{js,mjs}',
55
+ ],
56
+ presets: [titanConfig],
57
+ darkMode: 'class',
58
+ }
59
+ ```
60
+
61
+ The `titanConfig` preset includes `nativewind/preset` which registers the `web:` and
62
+ `native:` Tailwind variants that titan components use (e.g., `web:hover:bg-gray-100`).
63
+
64
+ ### 4. Configure PostCSS
65
+
66
+ ```js
67
+ // postcss.config.js
68
+ module.exports = {
69
+ plugins: {
70
+ tailwindcss: {},
71
+ autoprefixer: {},
72
+ },
73
+ }
74
+ ```
75
+
76
+ ### 5. Import global CSS
77
+
78
+ ```tsx
79
+ // main.tsx or App.tsx
80
+ import '@titan-design/react-ui/theme/global.css'
81
+ ```
82
+
83
+ This loads the design token CSS custom properties (colors, typography, spacing, etc.).
84
+
85
+ ### 6. Use components
86
+
87
+ ```tsx
88
+ import { Button, ButtonText, Card, CardContent } from '@titan-design/react-ui'
89
+
90
+ function App() {
91
+ return (
92
+ <Card>
93
+ <CardContent>
94
+ <Button color="primary">
95
+ <ButtonText>Hello</ButtonText>
96
+ </Button>
97
+ </CardContent>
98
+ </Card>
99
+ )
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## How It Works
106
+
107
+ Titan's web distribution (the `dist/` you import) uses a custom JSX runtime
108
+ that converts `className` props to
109
+ [`$$css` style objects](https://github.com/nicholasxjy/styleq#compiled-styles)
110
+ at **build time**. react-native-web's `styleq` recognizes these objects and
111
+ applies their values as CSS class names on DOM elements.
112
+
113
+ This means:
114
+ - No NativeWind runtime needed on web
115
+ - No `interopComponents` Map lookup (which breaks under Vite module deduplication)
116
+ - No `jsxImportSource: 'nativewind'` configuration needed
117
+ - className and inline `style` work together correctly (inline style wins on conflicts)
118
+
119
+ ### Native React Native
120
+
121
+ On native (iOS/Android), Metro resolves the `react-native` export condition in
122
+ titan's `package.json`, which points to source files. NativeWind's Babel plugin
123
+ (configured in your Metro/Expo setup) handles `className` natively.
124
+
125
+ ---
126
+
127
+ ## Migration from NativeWind-based Setup
128
+
129
+ If you previously configured NativeWind's runtime interop, here's what to remove:
130
+
131
+ ### Remove from `vite.config.ts`
132
+
133
+ ```diff
134
+ plugins: [
135
+ - react({ jsxImportSource: 'nativewind' }),
136
+ + react(),
137
+ ],
138
+ resolve: {
139
+ alias: { 'react-native': 'react-native-web' },
140
+ - dedupe: ['react-native-web'],
141
+ },
142
+ - optimizeDeps: {
143
+ - esbuildOptions: {
144
+ - loader: { '.js': 'jsx' },
145
+ - },
146
+ - },
147
+ ```
148
+
149
+ ### Remove `setup-interop.ts`
150
+
151
+ Delete any file that re-registers `cssInterop` for View/Text/Pressable.
152
+ This was a workaround for Vite's module deduplication breaking
153
+ NativeWind's `interopComponents` Map. It is no longer needed.
154
+
155
+ ```diff
156
+ // main.tsx
157
+ - import './setup-interop'
158
+ import '@titan-design/react-ui/theme/global.css'
159
+ ```
160
+
161
+ ### Remove runtime dependencies
162
+
163
+ ```bash
164
+ npm uninstall nativewind react-native-css-interop
165
+ npm install -D nativewind # keep as dev dep for Tailwind preset only
166
+ ```
167
+
168
+ ### Remove `conditions` from Vite resolve
169
+
170
+ If you were using `conditions: ['react-native']` to resolve titan's source
171
+ files, remove it. The dist now handles className conversion internally.
172
+
173
+ ```diff
174
+ resolve: {
175
+ - conditions: ['react-native', 'import'],
176
+ alias: { 'react-native': 'react-native-web' },
177
+ },
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Troubleshooting
183
+
184
+ ### Tailwind classes not appearing
185
+
186
+ 1. Verify your `tailwind.config.js` content array includes titan's dist:
187
+ ```js
188
+ content: [
189
+ './node_modules/@titan-design/react-ui/dist/**/*.{js,mjs}',
190
+ ]
191
+ ```
192
+ 2. Verify PostCSS is configured with the `tailwindcss` plugin
193
+ 3. Verify you imported `@titan-design/react-ui/theme/global.css`
194
+
195
+ ### Layout properties not applying (flexDirection, alignItems, etc.)
196
+
197
+ This was the original NativeWind interop issue. If you see this after upgrading,
198
+ ensure you've updated `@titan-design/react-ui` to a version with the custom
199
+ JSX runtime (0.3.0+). Older versions require the NativeWind runtime setup.
200
+
201
+ ### Your own `<View className="...">` components don't get styles
202
+
203
+ Titan's custom JSX runtime only applies to titan's pre-built dist. If your app
204
+ code directly uses React Native primitives with className, you have two options:
205
+
206
+ 1. **Use standard CSS/Tailwind on web**: Write `<div className="flex-row">` for
207
+ your own web-only components
208
+ 2. **Set up NativeWind for your app's JSX**: Add `jsxImportSource: 'nativewind'`
209
+ to your Vite React plugin config. This only affects your app's source files
210
+ (titan's dist is already handled)
211
+
212
+ ### Using titan with Webpack instead of Vite
213
+
214
+ The same principle applies. You need:
215
+ 1. `react-native` → `react-native-web` alias (via `resolve.alias` in webpack config)
216
+ 2. Tailwind CSS configured with titan's content paths
217
+ 3. No NativeWind runtime setup needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/react-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.6",
4
4
  "description": "Cross-platform design system built on Gluestack UI",
5
5
  "author": "Henry Jewkes",
6
6
  "license": "MIT",
@@ -41,23 +41,33 @@
41
41
  "build": "tsup",
42
42
  "dev": "tsup --watch",
43
43
  "lint": "eslint src/",
44
+ "lint:borders": "bash scripts/check-border-classes.sh",
44
45
  "type-check": "tsc --noEmit",
45
46
  "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
46
47
  "test": "vitest",
47
48
  "test:coverage": "vitest --coverage",
48
49
  "storybook": "storybook dev -p 6006",
49
50
  "build-storybook": "storybook build",
51
+ "test:visual": "playwright test",
52
+ "test:visual:update": "playwright test --update-snapshots",
53
+ "specimen": "vite --config specimen/vite.config.ts",
54
+ "specimen:compare": "vite --config specimen/vite.config.ts --open /comparison.html",
55
+ "test:visual:compare": "playwright test --config playwright.comparison.config.ts",
50
56
  "prepublishOnly": "tsup && vitest --run"
51
57
  },
52
58
  "peerDependencies": {
53
59
  "lucide-react": ">=0.400.0",
54
60
  "lucide-react-native": ">=0.400.0",
61
+ "nativewind": "^4.2.1",
55
62
  "react": "^18.0.0 || ^19.0.0",
56
63
  "react-dom": "^18.0.0 || ^19.0.0",
57
64
  "react-native": ">=0.72.0",
58
65
  "react-native-web": ">=0.19.0"
59
66
  },
60
67
  "peerDependenciesMeta": {
68
+ "nativewind": {
69
+ "optional": true
70
+ },
61
71
  "react-native": {
62
72
  "optional": true
63
73
  },
@@ -72,11 +82,12 @@
72
82
  "@gluestack-style/react": "^1.0.0",
73
83
  "@gluestack-ui/themed": "^1.1.0",
74
84
  "clsx": "^2.1.0",
75
- "nativewind": "^4.1.0",
76
85
  "tailwind-merge": "^2.6.0"
77
86
  },
78
87
  "devDependencies": {
88
+ "nativewind": "^4.2.1",
79
89
  "@eslint/js": "^10.0.1",
90
+ "@playwright/test": "^1.58.2",
80
91
  "@storybook/addon-a11y": "^10.2.0",
81
92
  "@storybook/addon-docs": "^10.2.0",
82
93
  "@storybook/addon-react-native-web": "^0.0.29",
@@ -112,6 +123,7 @@
112
123
  "files": [
113
124
  "dist",
114
125
  "src",
126
+ "docs",
115
127
  "tailwind.config.js"
116
128
  ],
117
129
  "sideEffects": false,
@@ -0,0 +1,71 @@
1
+ import React from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { RestTimer } from './RestTimer'
4
+
5
+ const meta: Meta<typeof RestTimer> = {
6
+ title: 'Custom/Workout/RestTimer',
7
+ component: RestTimer,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ totalSeconds: {
11
+ control: 'number',
12
+ description: 'Total rest duration in seconds',
13
+ },
14
+ elapsedMs: {
15
+ control: 'number',
16
+ description: 'Elapsed time in milliseconds',
17
+ },
18
+ visible: {
19
+ control: 'boolean',
20
+ description: 'Whether the timer is visible',
21
+ },
22
+ nextSetInfo: {
23
+ control: 'text',
24
+ description: 'Context about the next set',
25
+ },
26
+ },
27
+ }
28
+
29
+ export default meta
30
+ type Story = StoryObj<typeof RestTimer>
31
+
32
+ export const Default: Story = {
33
+ args: {
34
+ totalSeconds: 150,
35
+ elapsedMs: 30000,
36
+ onSkip: () => {},
37
+ onAddTime: () => {},
38
+ visible: true,
39
+ },
40
+ }
41
+
42
+ export const NearlyComplete: Story = {
43
+ args: {
44
+ totalSeconds: 150,
45
+ elapsedMs: 145000,
46
+ onSkip: () => {},
47
+ onAddTime: () => {},
48
+ visible: true,
49
+ },
50
+ }
51
+
52
+ export const WithNextSetInfo: Story = {
53
+ args: {
54
+ totalSeconds: 150,
55
+ elapsedMs: 30000,
56
+ onSkip: () => {},
57
+ onAddTime: () => {},
58
+ nextSetInfo: 'Bench Press — Set 3 of 4',
59
+ visible: true,
60
+ },
61
+ }
62
+
63
+ export const JustStarted: Story = {
64
+ args: {
65
+ totalSeconds: 150,
66
+ elapsedMs: 0,
67
+ onSkip: () => {},
68
+ onAddTime: () => {},
69
+ visible: true,
70
+ },
71
+ }
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { RestTimer } from './RestTimer'
5
+
6
+ const defaultProps = {
7
+ totalSeconds: 150,
8
+ elapsedMs: 30000,
9
+ onSkip: vi.fn(),
10
+ onAddTime: vi.fn(),
11
+ visible: true,
12
+ }
13
+
14
+ describe('RestTimer', () => {
15
+ describe('time display formatting', () => {
16
+ it('displays 2:30 for 150s total with 0 elapsed', () => {
17
+ render(<RestTimer {...defaultProps} elapsedMs={0} />)
18
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:30')
19
+ })
20
+
21
+ it('displays 2:00 for 150s total with 30s elapsed', () => {
22
+ render(<RestTimer {...defaultProps} elapsedMs={30000} />)
23
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:00')
24
+ })
25
+
26
+ it('displays 0:05 for 150s total with 145s elapsed', () => {
27
+ render(<RestTimer {...defaultProps} elapsedMs={145000} />)
28
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('0:05')
29
+ })
30
+
31
+ it('pads seconds with leading zero', () => {
32
+ render(<RestTimer {...defaultProps} totalSeconds={65} elapsedMs={0} />)
33
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('1:05')
34
+ })
35
+
36
+ it('displays 0:00 when elapsed equals total', () => {
37
+ render(<RestTimer {...defaultProps} elapsedMs={150000} />)
38
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('0:00')
39
+ })
40
+ })
41
+
42
+ describe('progress bar', () => {
43
+ it('sets width to 0% when elapsed is 0', () => {
44
+ render(<RestTimer {...defaultProps} elapsedMs={0} />)
45
+ const fill = screen.getByTestId('rest-timer-progress-fill')
46
+ expect(fill).toHaveStyle({ width: '0%' })
47
+ })
48
+
49
+ it('sets width to 50% when half elapsed', () => {
50
+ render(<RestTimer {...defaultProps} totalSeconds={100} elapsedMs={50000} />)
51
+ const fill = screen.getByTestId('rest-timer-progress-fill')
52
+ expect(fill).toHaveStyle({ width: '50%' })
53
+ })
54
+
55
+ it('clamps width to 100% when elapsed exceeds total', () => {
56
+ render(<RestTimer {...defaultProps} elapsedMs={200000} />)
57
+ const fill = screen.getByTestId('rest-timer-progress-fill')
58
+ expect(fill).toHaveStyle({ width: '100%' })
59
+ })
60
+ })
61
+
62
+ describe('button interactions', () => {
63
+ it('fires onSkip when skip button is pressed', () => {
64
+ const onSkip = vi.fn()
65
+ render(<RestTimer {...defaultProps} onSkip={onSkip} />)
66
+ fireEvent.click(screen.getByTestId('rest-timer-skip'))
67
+ expect(onSkip).toHaveBeenCalledOnce()
68
+ })
69
+
70
+ it('fires onAddTime when +30s button is pressed', () => {
71
+ const onAddTime = vi.fn()
72
+ render(<RestTimer {...defaultProps} onAddTime={onAddTime} />)
73
+ fireEvent.click(screen.getByTestId('rest-timer-add-time'))
74
+ expect(onAddTime).toHaveBeenCalledOnce()
75
+ })
76
+ })
77
+
78
+ describe('next set info', () => {
79
+ it('displays nextSetInfo when provided', () => {
80
+ render(
81
+ <RestTimer
82
+ {...defaultProps}
83
+ nextSetInfo="Bench Press — Set 3 of 4"
84
+ />,
85
+ )
86
+ expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
87
+ 'Bench Press — Set 3 of 4',
88
+ )
89
+ })
90
+
91
+ it('does not render next set element when nextSetInfo is not provided', () => {
92
+ render(<RestTimer {...defaultProps} />)
93
+ expect(screen.queryByTestId('rest-timer-next-set')).not.toBeInTheDocument()
94
+ })
95
+ })
96
+
97
+ describe('visibility', () => {
98
+ it('renders nothing when visible is false', () => {
99
+ render(<RestTimer {...defaultProps} visible={false} />)
100
+ expect(screen.queryByTestId('rest-timer')).not.toBeInTheDocument()
101
+ })
102
+
103
+ it('renders when visible is true', () => {
104
+ render(<RestTimer {...defaultProps} visible />)
105
+ expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
106
+ })
107
+ })
108
+
109
+ describe('edge cases', () => {
110
+ it('clamps remaining to 0 when elapsed exceeds total', () => {
111
+ render(<RestTimer {...defaultProps} elapsedMs={200000} />)
112
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('0:00')
113
+ })
114
+
115
+ it('displays full time when elapsed is 0', () => {
116
+ render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={0} />)
117
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('1:30')
118
+ })
119
+ })
120
+
121
+ describe('accessibility', () => {
122
+ it('has no accessibility violations', async () => {
123
+ const { container } = render(<RestTimer {...defaultProps} />)
124
+ const results = await axe(container)
125
+ expect(results).toHaveNoViolations()
126
+ })
127
+
128
+ it('has timer role with remaining seconds in label', () => {
129
+ render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={30000} />)
130
+ expect(
131
+ screen.getByLabelText('Rest timer, 60 seconds remaining'),
132
+ ).toBeInTheDocument()
133
+ })
134
+
135
+ it('skip button has accessible label', () => {
136
+ render(<RestTimer {...defaultProps} />)
137
+ expect(screen.getByLabelText('Skip rest')).toBeInTheDocument()
138
+ })
139
+
140
+ it('add time button has accessible label', () => {
141
+ render(<RestTimer {...defaultProps} />)
142
+ expect(screen.getByLabelText('Add 30 seconds')).toBeInTheDocument()
143
+ })
144
+ })
145
+ })
@@ -0,0 +1,172 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import React from 'react'
3
+ import { View, Text, Pressable } from 'react-native'
4
+
5
+ export interface RestTimerProps {
6
+ totalSeconds: number
7
+ elapsedMs: number
8
+ onSkip: () => void
9
+ onAddTime: () => void
10
+ nextSetInfo?: string
11
+ visible: boolean
12
+ }
13
+
14
+ export function RestTimer({
15
+ totalSeconds,
16
+ elapsedMs,
17
+ onSkip,
18
+ onAddTime,
19
+ nextSetInfo,
20
+ visible,
21
+ }: RestTimerProps) {
22
+ if (!visible) return null
23
+
24
+ const remainingMs = Math.max(0, totalSeconds * 1000 - elapsedMs)
25
+ const remainingSec = Math.ceil(remainingMs / 1000)
26
+ const minutes = Math.floor(remainingSec / 60)
27
+ const seconds = remainingSec % 60
28
+ const timeDisplay = `${minutes}:${String(seconds).padStart(2, '0')}`
29
+ const progressPct = Math.min(100, (elapsedMs / (totalSeconds * 1000)) * 100)
30
+
31
+ return (
32
+ <View
33
+ style={{
34
+ width: '100%',
35
+ backgroundColor: '#1C1C1C',
36
+ borderTopWidth: 1,
37
+ borderTopColor: '#1F1F1F',
38
+ paddingVertical: 12,
39
+ paddingHorizontal: 16,
40
+ }}
41
+ accessibilityRole="timer"
42
+ accessibilityLabel={`Rest timer, ${remainingSec} seconds remaining`}
43
+ testID="rest-timer"
44
+ >
45
+ {/* Top row */}
46
+ <View
47
+ style={{
48
+ flexDirection: 'row',
49
+ justifyContent: 'space-between',
50
+ alignItems: 'center',
51
+ marginBottom: 8,
52
+ }}
53
+ >
54
+ {/* Left side */}
55
+ <View style={{ flexDirection: 'column' }}>
56
+ <Text
57
+ style={{
58
+ fontSize: 11,
59
+ fontFamily: 'Inter, sans-serif',
60
+ fontWeight: '600',
61
+ textTransform: 'uppercase',
62
+ letterSpacing: 1,
63
+ color: '#9CA3AF',
64
+ }}
65
+ testID="rest-timer-label"
66
+ >
67
+ REST
68
+ </Text>
69
+ {nextSetInfo != null && (
70
+ <Text
71
+ style={{
72
+ fontSize: 11,
73
+ fontFamily: 'Inter, sans-serif',
74
+ color: '#6B7280',
75
+ marginTop: 2,
76
+ }}
77
+ testID="rest-timer-next-set"
78
+ >
79
+ {nextSetInfo}
80
+ </Text>
81
+ )}
82
+ </View>
83
+
84
+ {/* Right side - time display */}
85
+ <Text
86
+ style={{
87
+ fontSize: 28,
88
+ fontFamily: '"Space Grotesk", sans-serif',
89
+ fontWeight: '700',
90
+ color: '#F3F4F6',
91
+ fontVariant: ['tabular-nums'],
92
+ letterSpacing: -0.5,
93
+ }}
94
+ testID="rest-timer-time"
95
+ >
96
+ {timeDisplay}
97
+ </Text>
98
+ </View>
99
+
100
+ {/* Progress bar */}
101
+ <View
102
+ style={{
103
+ height: 3,
104
+ backgroundColor: '#1F1F1F',
105
+ borderRadius: 2,
106
+ marginBottom: 12,
107
+ }}
108
+ testID="rest-timer-progress-track"
109
+ >
110
+ <View
111
+ style={{
112
+ height: '100%',
113
+ backgroundColor: '#FF7900',
114
+ borderRadius: 2,
115
+ width: `${progressPct}%`,
116
+ }}
117
+ testID="rest-timer-progress-fill"
118
+ />
119
+ </View>
120
+
121
+ {/* Actions row */}
122
+ <View style={{ flexDirection: 'row', gap: 8 }}>
123
+ <Pressable
124
+ onPress={onAddTime}
125
+ style={{
126
+ backgroundColor: 'rgba(255,255,255,0.06)',
127
+ paddingVertical: 8,
128
+ paddingHorizontal: 20,
129
+ borderRadius: 8,
130
+ }}
131
+ accessibilityRole="button"
132
+ accessibilityLabel="Add 30 seconds"
133
+ testID="rest-timer-add-time"
134
+ >
135
+ <Text
136
+ style={{
137
+ fontSize: 11,
138
+ fontFamily: 'Inter, sans-serif',
139
+ fontWeight: '600',
140
+ color: '#9CA3AF',
141
+ }}
142
+ >
143
+ +30s
144
+ </Text>
145
+ </Pressable>
146
+ <Pressable
147
+ onPress={onSkip}
148
+ style={{
149
+ backgroundColor: 'rgba(255,121,0,0.12)',
150
+ paddingVertical: 8,
151
+ paddingHorizontal: 20,
152
+ borderRadius: 8,
153
+ }}
154
+ accessibilityRole="button"
155
+ accessibilityLabel="Skip rest"
156
+ testID="rest-timer-skip"
157
+ >
158
+ <Text
159
+ style={{
160
+ fontSize: 11,
161
+ fontFamily: 'Inter, sans-serif',
162
+ fontWeight: '600',
163
+ color: '#FF7900',
164
+ }}
165
+ >
166
+ Skip
167
+ </Text>
168
+ </Pressable>
169
+ </View>
170
+ </View>
171
+ )
172
+ }