@titan-design/react-ui 0.2.5 → 0.2.7
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/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +348 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +163 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +253 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +159 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +113 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.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.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Cross-platform design system built on Gluestack UI",
|
|
5
5
|
"author": "Henry Jewkes",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"node": ">=20.0.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"build": "tsup",
|
|
41
|
+
"build": "tsup && node scripts/generate-tokens-css.mjs",
|
|
42
42
|
"dev": "tsup --watch",
|
|
43
43
|
"lint": "eslint src/",
|
|
44
44
|
"lint:borders": "bash scripts/check-border-classes.sh",
|
|
@@ -62,19 +62,27 @@
|
|
|
62
62
|
"react": "^18.0.0 || ^19.0.0",
|
|
63
63
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
64
64
|
"react-native": ">=0.72.0",
|
|
65
|
+
"react-native-body-highlighter": ">=3.0.0",
|
|
66
|
+
"react-native-svg": ">=15.0.0",
|
|
65
67
|
"react-native-web": ">=0.19.0"
|
|
66
68
|
},
|
|
67
69
|
"peerDependenciesMeta": {
|
|
70
|
+
"lucide-react-native": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
68
73
|
"nativewind": {
|
|
69
74
|
"optional": true
|
|
70
75
|
},
|
|
71
76
|
"react-native": {
|
|
72
77
|
"optional": true
|
|
73
78
|
},
|
|
74
|
-
"react-native-
|
|
79
|
+
"react-native-body-highlighter": {
|
|
75
80
|
"optional": true
|
|
76
81
|
},
|
|
77
|
-
"
|
|
82
|
+
"react-native-svg": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"react-native-web": {
|
|
78
86
|
"optional": true
|
|
79
87
|
}
|
|
80
88
|
},
|
|
@@ -85,7 +93,6 @@
|
|
|
85
93
|
"tailwind-merge": "^2.6.0"
|
|
86
94
|
},
|
|
87
95
|
"devDependencies": {
|
|
88
|
-
"nativewind": "^4.2.1",
|
|
89
96
|
"@eslint/js": "^10.0.1",
|
|
90
97
|
"@playwright/test": "^1.58.2",
|
|
91
98
|
"@storybook/addon-a11y": "^10.2.0",
|
|
@@ -108,9 +115,13 @@
|
|
|
108
115
|
"jest-axe": "^9.0.0",
|
|
109
116
|
"jsdom": "^25.0.0",
|
|
110
117
|
"lucide-react": "^0.469.0",
|
|
118
|
+
"nativewind": "^4.2.1",
|
|
111
119
|
"prettier": "^3.8.1",
|
|
112
120
|
"react": "^18.3.0",
|
|
113
121
|
"react-dom": "^18.3.0",
|
|
122
|
+
"react-hook-form": "^7.54.2",
|
|
123
|
+
"react-native-body-highlighter": "^3.2.0",
|
|
124
|
+
"react-native-svg": "^15.15.5",
|
|
114
125
|
"react-native-web": "^0.19.0",
|
|
115
126
|
"storybook": "^10.2.0",
|
|
116
127
|
"tailwindcss": "^3.4.0",
|
|
@@ -123,6 +134,7 @@
|
|
|
123
134
|
"files": [
|
|
124
135
|
"dist",
|
|
125
136
|
"src",
|
|
137
|
+
"docs",
|
|
126
138
|
"tailwind.config.js"
|
|
127
139
|
],
|
|
128
140
|
"sideEffects": false,
|
|
@@ -169,7 +169,7 @@ export const UsageExample: Story = {
|
|
|
169
169
|
<View className="gap-2 p-4 bg-surface-elevated rounded-lg max-w-md">
|
|
170
170
|
<Text className="text-lg font-semibold text-text-primary mb-2">Upcoming Events</Text>
|
|
171
171
|
{items.map((item, index) => (
|
|
172
|
-
<View key={index} className="flex-row justify-between items-center py-2 border-b border-border
|
|
172
|
+
<View key={index} className="flex-row justify-between items-center py-2 border-b border-border">
|
|
173
173
|
<Text className="text-text-primary">{item.title}</Text>
|
|
174
174
|
<DateTime value={item.date} format="relative" color="secondary" className="text-sm" />
|
|
175
175
|
</View>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Gauge } from './Gauge'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Gauge> = {
|
|
5
|
+
title: 'Custom/Gauge',
|
|
6
|
+
component: Gauge,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
|
|
10
|
+
size: { control: { type: 'range', min: 100, max: 320, step: 10 } },
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof Gauge>
|
|
15
|
+
|
|
16
|
+
export const Healthy: Story = {
|
|
17
|
+
args: { value: 88, unit: '%', label: 'Health', size: 180 },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Warning: Story = {
|
|
21
|
+
args: { value: 68, unit: '%', label: 'Health', size: 180 },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Critical: Story = {
|
|
25
|
+
args: { value: 34, unit: '%', label: 'Health', size: 180 },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A codewatch-style status readout — arbitrary domain, no unit. */
|
|
29
|
+
export const ArbitraryDomain: Story = {
|
|
30
|
+
args: { value: 7.4, min: 0, max: 10, label: 'Maintainability', size: 200 },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Single-color override ignores the threshold bands. */
|
|
34
|
+
export const OverrideColor: Story = {
|
|
35
|
+
args: { value: 55, unit: '%', label: 'Coverage', color: '#5B9BD5', size: 180 },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Value above max is clamped for the fill but shown verbatim. */
|
|
39
|
+
export const OverMax: Story = {
|
|
40
|
+
args: { value: 120, unit: '%', label: 'Load', size: 180 },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Zero: Story = {
|
|
44
|
+
args: { value: 0, unit: '%', label: 'Health', size: 180 },
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Gauge } from './Gauge'
|
|
5
|
+
|
|
6
|
+
describe('Gauge', () => {
|
|
7
|
+
it('renders the arc as a fixed set of segments', () => {
|
|
8
|
+
render(<Gauge value={50} />)
|
|
9
|
+
expect(screen.getAllByTestId('gauge-segment')).toHaveLength(40)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders the value, unit, and label', () => {
|
|
13
|
+
render(<Gauge value={82} unit="%" label="Health" />)
|
|
14
|
+
expect(screen.getByTestId('gauge-value')).toHaveTextContent('82')
|
|
15
|
+
expect(screen.getByTestId('gauge-label')).toHaveTextContent('Health')
|
|
16
|
+
expect(screen.getByText('%')).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('colors the value by the active status band', () => {
|
|
20
|
+
const { rerender } = render(<Gauge value={30} />)
|
|
21
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#D14343' })
|
|
22
|
+
rerender(<Gauge value={70} />)
|
|
23
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#FFB020' })
|
|
24
|
+
rerender(<Gauge value={90} />)
|
|
25
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#14B8A6' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('honors a single-color override over thresholds', () => {
|
|
29
|
+
render(<Gauge value={10} color="#5B9BD5" />)
|
|
30
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#5B9BD5' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('clamps an out-of-range value without crashing', () => {
|
|
34
|
+
render(<Gauge value={150} unit="%" />)
|
|
35
|
+
// Displayed value is verbatim; only the fill is clamped.
|
|
36
|
+
expect(screen.getByTestId('gauge-value')).toHaveTextContent('150')
|
|
37
|
+
expect(screen.getAllByTestId('gauge-segment')).toHaveLength(40)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('supports an arbitrary min/max domain', () => {
|
|
41
|
+
render(<Gauge value={5} min={0} max={10} label="Score" />)
|
|
42
|
+
expect(screen.getByTestId('gauge-value')).toHaveTextContent('5')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('respects custom thresholds', () => {
|
|
46
|
+
render(
|
|
47
|
+
<Gauge
|
|
48
|
+
value={45}
|
|
49
|
+
thresholds={[
|
|
50
|
+
{ value: 0, color: '#14B8A6' },
|
|
51
|
+
{ value: 50, color: '#D14343' },
|
|
52
|
+
]}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#14B8A6' })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('accessibility', () => {
|
|
59
|
+
it('exposes an image role with a descriptive label', () => {
|
|
60
|
+
render(<Gauge value={82} unit="%" label="Health" />)
|
|
61
|
+
const gauge = screen.getByTestId('gauge')
|
|
62
|
+
expect(gauge).toHaveAttribute('role', 'img')
|
|
63
|
+
expect(gauge.getAttribute('aria-label')).toContain('Health: 82% of 100')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('has no accessibility violations', async () => {
|
|
67
|
+
const { container } = render(<Gauge value={82} unit="%" label="Health" />)
|
|
68
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
2
|
+
import { cn } from '../../../utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface GaugeThreshold {
|
|
5
|
+
/** Band start, in the gauge's value units. */
|
|
6
|
+
value: number
|
|
7
|
+
/** Color applied to filled segments at or above this band. */
|
|
8
|
+
color: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GaugeProps extends Omit<ViewProps, 'children'> {
|
|
12
|
+
/** Current value. Clamped to [min, max]. */
|
|
13
|
+
value: number
|
|
14
|
+
min?: number
|
|
15
|
+
max?: number
|
|
16
|
+
/** Diameter in px. */
|
|
17
|
+
size?: number
|
|
18
|
+
/** Caption under the big number. */
|
|
19
|
+
label?: string
|
|
20
|
+
/** Unit suffix beside the big number (e.g. "%"). */
|
|
21
|
+
unit?: string
|
|
22
|
+
/**
|
|
23
|
+
* Band coloring, ascending by `value`. A filled segment takes the color of
|
|
24
|
+
* the highest band whose `value` it reaches. Defaults to Titan status tokens
|
|
25
|
+
* (red < 60, amber < 80, green ≥ 80 on a 0–100 scale).
|
|
26
|
+
*/
|
|
27
|
+
thresholds?: GaugeThreshold[]
|
|
28
|
+
/** Single-color override for filled segments (ignores `thresholds`). */
|
|
29
|
+
color?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STATUS_SUCCESS = '#14B8A6'
|
|
34
|
+
const STATUS_WARNING = '#FFB020'
|
|
35
|
+
const STATUS_ERROR = '#D14343'
|
|
36
|
+
const TRACK = 'rgba(255,255,255,0.08)'
|
|
37
|
+
|
|
38
|
+
/** Titan status-token bands for a 0–100 score. */
|
|
39
|
+
const DEFAULT_THRESHOLDS: GaugeThreshold[] = [
|
|
40
|
+
{ value: 0, color: STATUS_ERROR },
|
|
41
|
+
{ value: 60, color: STATUS_WARNING },
|
|
42
|
+
{ value: 80, color: STATUS_SUCCESS },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const ARC_START = 135
|
|
46
|
+
const ARC_SWEEP = 270
|
|
47
|
+
const SEGMENTS = 40
|
|
48
|
+
|
|
49
|
+
function clamp01(n: number): number {
|
|
50
|
+
if (Number.isNaN(n)) return 0
|
|
51
|
+
return Math.max(0, Math.min(1, n))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatValue(v: number): string {
|
|
55
|
+
return Number.isInteger(v) ? `${v}` : v.toFixed(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Color of the highest band whose start value the fraction reaches. */
|
|
59
|
+
function bandColor(fraction: number, bands: GaugeThreshold[], min: number, max: number): string {
|
|
60
|
+
const value = min + fraction * (max - min)
|
|
61
|
+
const sorted = [...bands].sort((a, b) => a.value - b.value)
|
|
62
|
+
let color = sorted[0]?.color ?? STATUS_SUCCESS
|
|
63
|
+
for (const band of sorted) {
|
|
64
|
+
if (value >= band.value) color = band.color
|
|
65
|
+
}
|
|
66
|
+
return color
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Tick {
|
|
70
|
+
key: number
|
|
71
|
+
left: number
|
|
72
|
+
top: number
|
|
73
|
+
rotate: string
|
|
74
|
+
color: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Position + color each radial segment of the arc. */
|
|
78
|
+
function buildTicks(fill: number, size: number, activeColor: (f: number) => string): Tick[] {
|
|
79
|
+
const center = size / 2
|
|
80
|
+
const radius = center - size * 0.08
|
|
81
|
+
return Array.from({ length: SEGMENTS }, (_, i) => {
|
|
82
|
+
const fraction = (i + 0.5) / SEGMENTS
|
|
83
|
+
const deg = ARC_START + fraction * ARC_SWEEP
|
|
84
|
+
const rad = (deg * Math.PI) / 180
|
|
85
|
+
return {
|
|
86
|
+
key: i,
|
|
87
|
+
left: center + radius * Math.cos(rad),
|
|
88
|
+
top: center + radius * Math.sin(rad),
|
|
89
|
+
rotate: `${deg - 90}deg`,
|
|
90
|
+
color: fraction <= fill ? activeColor(fraction) : TRACK,
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* SVG-free radial gauge (absolutely-positioned segment Views), matching the
|
|
97
|
+
* codebase's chart convention so it renders identically on web and native. The
|
|
98
|
+
* 270° arc fills with band colors as the value rises; the center reuses the
|
|
99
|
+
* Metric visual language (large bold value + unit + caption). Ideal for a 0–100
|
|
100
|
+
* health / quality score.
|
|
101
|
+
*/
|
|
102
|
+
export function Gauge({
|
|
103
|
+
value,
|
|
104
|
+
min = 0,
|
|
105
|
+
max = 100,
|
|
106
|
+
size = 160,
|
|
107
|
+
label,
|
|
108
|
+
unit,
|
|
109
|
+
thresholds = DEFAULT_THRESHOLDS,
|
|
110
|
+
color,
|
|
111
|
+
className,
|
|
112
|
+
...props
|
|
113
|
+
}: GaugeProps) {
|
|
114
|
+
const span = max - min || 1
|
|
115
|
+
const fill = clamp01((value - min) / span)
|
|
116
|
+
const activeColor = (f: number) => color ?? bandColor(f, thresholds, min, max)
|
|
117
|
+
const ticks = buildTicks(fill, size, activeColor)
|
|
118
|
+
const displayColor = color ?? bandColor(fill, thresholds, min, max)
|
|
119
|
+
const tickLength = size * 0.11
|
|
120
|
+
const tickThickness = Math.max(2, (size * 0.9) / SEGMENTS)
|
|
121
|
+
|
|
122
|
+
const ariaLabel = `${label ? `${label}: ` : ''}${formatValue(value)}${unit ?? ''} of ${formatValue(max)}`
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View
|
|
126
|
+
className={cn('relative', className)}
|
|
127
|
+
style={{ width: size, height: size }}
|
|
128
|
+
accessibilityRole="image"
|
|
129
|
+
accessibilityLabel={ariaLabel}
|
|
130
|
+
testID="gauge"
|
|
131
|
+
{...props}
|
|
132
|
+
>
|
|
133
|
+
{ticks.map((t) => (
|
|
134
|
+
<View
|
|
135
|
+
key={t.key}
|
|
136
|
+
accessibilityElementsHidden
|
|
137
|
+
testID="gauge-segment"
|
|
138
|
+
style={{
|
|
139
|
+
position: 'absolute',
|
|
140
|
+
left: t.left - tickThickness / 2,
|
|
141
|
+
top: t.top - tickLength / 2,
|
|
142
|
+
width: tickThickness,
|
|
143
|
+
height: tickLength,
|
|
144
|
+
borderRadius: tickThickness / 2,
|
|
145
|
+
backgroundColor: t.color,
|
|
146
|
+
transform: [{ rotate: t.rotate }],
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
|
|
151
|
+
{/* Center readout — Metric visual language. */}
|
|
152
|
+
<View style={{ position: 'absolute', top: 0, left: 0, width: size, height: size }} className="items-center justify-center">
|
|
153
|
+
<View className="flex-row items-baseline gap-0.5">
|
|
154
|
+
<Text testID="gauge-value" className="text-3xl font-bold" style={{ color: displayColor }}>
|
|
155
|
+
{formatValue(value)}
|
|
156
|
+
</Text>
|
|
157
|
+
{unit && <Text className="text-sm text-text-tertiary">{unit}</Text>}
|
|
158
|
+
</View>
|
|
159
|
+
{label && (
|
|
160
|
+
<Text testID="gauge-label" className="text-xs text-text-secondary mt-1">
|
|
161
|
+
{label}
|
|
162
|
+
</Text>
|
|
163
|
+
)}
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Scatter, type ScatterDatum } from './Scatter'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Scatter> = {
|
|
5
|
+
title: 'Custom/Scatter',
|
|
6
|
+
component: Scatter,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
width: { control: { type: 'range', min: 240, max: 800, step: 20 } },
|
|
10
|
+
height: { control: { type: 'range', min: 180, max: 600, step: 20 } },
|
|
11
|
+
diagonal: { control: 'boolean' },
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof Scatter>
|
|
16
|
+
|
|
17
|
+
/** Modules on the instability × abstractness "main sequence". */
|
|
18
|
+
const mainSequence: ScatterDatum[] = [
|
|
19
|
+
{ id: 'core', x: 0.08, y: 0.85, r: 10, color: '#14B8A6', label: 'core' },
|
|
20
|
+
{ id: 'domain', x: 0.22, y: 0.7, r: 8, color: '#14B8A6', label: 'domain' },
|
|
21
|
+
{ id: 'services', x: 0.45, y: 0.4, r: 12, color: '#5B9BD5', label: 'services' },
|
|
22
|
+
{ id: 'api', x: 0.6, y: 0.35, r: 7, color: '#5B9BD5', label: 'api' },
|
|
23
|
+
{ id: 'cli', x: 0.9, y: 0.08, r: 9, color: '#F4A736', label: 'cli' },
|
|
24
|
+
{ id: 'utils', x: 0.85, y: 0.75, r: 6, color: '#F83030', label: 'utils (pain?)' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
data: mainSequence,
|
|
30
|
+
width: 480,
|
|
31
|
+
height: 340,
|
|
32
|
+
diagonal: true,
|
|
33
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const NoDiagonal: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
data: mainSequence,
|
|
40
|
+
width: 480,
|
|
41
|
+
height: 340,
|
|
42
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Selected: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
data: mainSequence,
|
|
49
|
+
width: 480,
|
|
50
|
+
height: 340,
|
|
51
|
+
diagonal: true,
|
|
52
|
+
selectedId: 'utils',
|
|
53
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Auto-domain from a single point — the frame still renders cleanly. */
|
|
58
|
+
export const SinglePoint: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
data: [{ id: 'only', x: 0.5, y: 0.5, r: 10, label: 'only module' }],
|
|
61
|
+
width: 400,
|
|
62
|
+
height: 300,
|
|
63
|
+
axis: { xLabel: 'I', yLabel: 'A' },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An outlier far outside the main cluster, kept legible by an explicit domain. */
|
|
68
|
+
export const Outlier: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
data: [...mainSequence, { id: 'legacy', x: 0.98, y: 0.98, r: 16, color: '#F83030', label: 'legacy.ts' }],
|
|
71
|
+
width: 480,
|
|
72
|
+
height: 340,
|
|
73
|
+
diagonal: true,
|
|
74
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const Empty: Story = {
|
|
79
|
+
args: {
|
|
80
|
+
data: [],
|
|
81
|
+
width: 400,
|
|
82
|
+
height: 300,
|
|
83
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
84
|
+
},
|
|
85
|
+
}
|