@startsimpli/ui-native 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +174 -2
- package/package.json +60 -3
- package/src/__tests__/charts-math.test.ts +73 -0
- package/src/__tests__/styles-dashboard.test.ts +92 -0
- package/src/__tests__/styles-primitives.test.ts +114 -0
- package/src/__tests__/styles.test.ts +101 -0
- package/src/__tests__/theme.test.ts +23 -0
- package/src/__tests__/tokens.test.ts +33 -0
- package/src/charts-math.ts +106 -0
- package/src/components/AccentContext.tsx +22 -0
- package/src/components/Alert.tsx +27 -0
- package/src/components/Avatar.tsx +42 -0
- package/src/components/Badge.tsx +18 -0
- package/src/components/Button.tsx +40 -0
- package/src/components/Card.tsx +30 -0
- package/src/components/Checkbox.tsx +31 -0
- package/src/components/Chip.tsx +26 -0
- package/src/components/CompareChart.tsx +56 -0
- package/src/components/DataTable.tsx +89 -0
- package/src/components/DemographicBars.tsx +50 -0
- package/src/components/DonutChart.tsx +57 -0
- package/src/components/DonutLegend.tsx +49 -0
- package/src/components/DualRangeControl.tsx +31 -0
- package/src/components/EmptyState.tsx +63 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/Flag.tsx +26 -0
- package/src/components/GradientBox.tsx +48 -0
- package/src/components/HeroBanner.tsx +160 -0
- package/src/components/Input.tsx +33 -0
- package/src/components/Label.tsx +9 -0
- package/src/components/ListRow.tsx +44 -0
- package/src/components/MetricRow.tsx +48 -0
- package/src/components/MultiLineLegend.tsx +30 -0
- package/src/components/Progress.tsx +18 -0
- package/src/components/Screen.tsx +38 -0
- package/src/components/SegmentedControl.tsx +31 -0
- package/src/components/Separator.tsx +13 -0
- package/src/components/ShareBar.tsx +32 -0
- package/src/components/Skeleton.tsx +29 -0
- package/src/components/SkeletonList.tsx +23 -0
- package/src/components/Sparkline.tsx +38 -0
- package/src/components/Spinner.tsx +8 -0
- package/src/components/StatTile.tsx +69 -0
- package/src/components/Switch.tsx +17 -0
- package/src/components/TabBar.tsx +50 -0
- package/src/components/Text.tsx +13 -0
- package/src/components/ThemeProvider.tsx +25 -0
- package/src/components/Timeline.tsx +47 -0
- package/src/components/TrendCard.tsx +39 -0
- package/src/components/TrendChart.tsx +68 -0
- package/src/index.ts +64 -0
- package/src/styles.ts +479 -0
- package/src/theme.ts +46 -0
- package/src/tokens.ts +145 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StartSimpli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @startsimpli/ui-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React Native UI primitives for StartSimpli apps — the native counterpart to
|
|
4
|
+
[`@startsimpli/ui`](../ui) (web/Tailwind). Both draw from the same design-token
|
|
5
|
+
values; see [`./src/tokens.ts`](./src/tokens.ts) and
|
|
6
|
+
[`packages/ui/theme/contract.css`](../ui/theme/contract.css).
|
|
7
|
+
|
|
8
|
+
> **Status: new package, in progress.** This is part of the
|
|
9
|
+
> `react-native-enablement` initiative. The token system and the 30+ primitives
|
|
10
|
+
> below are live and tested, but the React Native parity story is still being
|
|
11
|
+
> built out — the `examples/mobile-rn` Expo demo is the canonical consumer
|
|
12
|
+
> today.
|
|
13
|
+
|
|
14
|
+
Ships raw TypeScript source; Metro/Expo bundles it directly.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm add @startsimpli/ui-native
|
|
20
|
+
|
|
21
|
+
# Required peers:
|
|
22
|
+
pnpm add react react-native
|
|
23
|
+
|
|
24
|
+
# Optional peers (Screen safe-area insets, chart primitives):
|
|
25
|
+
pnpm add react-native-safe-area-context react-native-svg
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`react-native-safe-area-context` is required for `Screen`'s inset handling and
|
|
29
|
+
the `SafeAreaProvider` that `ThemeProvider` mounts. `react-native-svg` is
|
|
30
|
+
required only for the chart primitives (`Sparkline`, `DonutChart`,
|
|
31
|
+
`TrendChart`, `TrendCard`).
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import {
|
|
37
|
+
ThemeProvider,
|
|
38
|
+
Screen,
|
|
39
|
+
Card,
|
|
40
|
+
CardHeader,
|
|
41
|
+
CardTitle,
|
|
42
|
+
CardDescription,
|
|
43
|
+
CardContent,
|
|
44
|
+
Text,
|
|
45
|
+
Button,
|
|
46
|
+
} from '@startsimpli/ui-native';
|
|
47
|
+
|
|
48
|
+
export default function App() {
|
|
49
|
+
return (
|
|
50
|
+
<ThemeProvider>
|
|
51
|
+
<Screen scroll>
|
|
52
|
+
<Card>
|
|
53
|
+
<CardHeader>
|
|
54
|
+
<CardTitle>Hello</CardTitle>
|
|
55
|
+
<CardDescription>Rendered from shared primitives.</CardDescription>
|
|
56
|
+
</CardHeader>
|
|
57
|
+
<CardContent>
|
|
58
|
+
<Text variant="muted">Tap to continue.</Text>
|
|
59
|
+
<Button title="Continue" variant="primary" />
|
|
60
|
+
</CardContent>
|
|
61
|
+
</Card>
|
|
62
|
+
</Screen>
|
|
63
|
+
</ThemeProvider>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`ThemeProvider` follows the OS light/dark scheme by default (pass an explicit
|
|
69
|
+
`theme={lightTheme | darkTheme | customTheme}` to pin it) and mounts a
|
|
70
|
+
`SafeAreaProvider` so `Screen` can apply insets.
|
|
71
|
+
|
|
72
|
+
## Design tokens
|
|
73
|
+
|
|
74
|
+
Tokens are the single source for color, spacing, type scale, radii, and
|
|
75
|
+
animation durations. Available both as a barrel (`@startsimpli/ui-native`)
|
|
76
|
+
and at a stable subpath:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { palette, hsl, radii, spacing, fontSizes, fontWeights, durations }
|
|
80
|
+
from '@startsimpli/ui-native/tokens';
|
|
81
|
+
|
|
82
|
+
import { makeTheme, lightTheme, darkTheme, type Theme }
|
|
83
|
+
from '@startsimpli/ui-native';
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Colors are stored as HSL channel strings (`"221.2 83.2% 53.3%"`) that mirror
|
|
87
|
+
the CSS custom properties in `packages/ui/theme/contract.css` — the same source
|
|
88
|
+
the web Tailwind preset reads via `hsl(var(--token))`. The `hsl()` helper
|
|
89
|
+
converts a channel string into a React Native–parseable
|
|
90
|
+
`hsl(h, s%, l%)` color, and `makeTheme(scheme)` resolves a whole palette into
|
|
91
|
+
a ready-to-use `Theme` object.
|
|
92
|
+
|
|
93
|
+
> **Cross-link: [startsim-sw1.8](../../.beads/)** — *Single-source design
|
|
94
|
+
> tokens between ui-native `tokens.ts` and web Tailwind/`contract.css`.*
|
|
95
|
+
> Today the channel-string palette in `tokens.ts` is hand-mirrored from
|
|
96
|
+
> `contract.css`. The plan is to derive one from the other so a single token
|
|
97
|
+
> edit flows to both web and native (parent: `startsim-sw1` "RN-enable shared
|
|
98
|
+
> packages"). Until that lands, treat any token change as a two-file edit.
|
|
99
|
+
|
|
100
|
+
## Public surface
|
|
101
|
+
|
|
102
|
+
### Theming + layout
|
|
103
|
+
|
|
104
|
+
| Export | Purpose |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `ThemeProvider` / `useTheme` | OS-following light/dark, pinnable via `theme` prop. Provides `Theme` to every primitive. |
|
|
107
|
+
| `Screen` | Top-level scrolling/non-scrolling container with safe-area insets. |
|
|
108
|
+
| `ErrorBoundary` | Surface render errors as a styled fallback. |
|
|
109
|
+
| `AccentProvider` / `useAccent` | Per-subtree accent override (e.g. brand-colored section). |
|
|
110
|
+
|
|
111
|
+
### Primitives
|
|
112
|
+
|
|
113
|
+
`Text`, `Button`, `Badge`, `Label`, `Input`, `Separator`, `Checkbox`, `Switch`,
|
|
114
|
+
`Progress`, `Alert`, `Skeleton`, `SkeletonList`, `Spinner`, `Avatar`, `Chip`,
|
|
115
|
+
`SegmentedControl`, `TabBar`, `ShareBar` — and the `Card` composition
|
|
116
|
+
(`Card` / `CardHeader` / `CardTitle` / `CardDescription` / `CardContent`).
|
|
117
|
+
|
|
118
|
+
### Composed building blocks
|
|
119
|
+
|
|
120
|
+
`StatTile`, `EmptyState`, `ListRow`, `MetricRow`, `Timeline`, `DataTable`,
|
|
121
|
+
`HeroBanner`, `GradientBox`.
|
|
122
|
+
|
|
123
|
+
### Charts (require `react-native-svg`)
|
|
124
|
+
|
|
125
|
+
`Sparkline`, `DonutChart`, `DonutLegend`, `TrendChart`, `TrendCard`, plus the
|
|
126
|
+
pure math helpers re-exported from `charts-math` (used by the components and
|
|
127
|
+
safely importable in tests).
|
|
128
|
+
|
|
129
|
+
### Pure style builders
|
|
130
|
+
|
|
131
|
+
Every component's style logic is split into `./src/styles.ts` so it can be
|
|
132
|
+
unit-tested without a React Native renderer. The builders are re-exported for
|
|
133
|
+
custom components that want the same look:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { buttonContainerStyle, buttonTextStyle, badgeStyle, /* ... */ }
|
|
137
|
+
from '@startsimpli/ui-native';
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Dark mode
|
|
141
|
+
|
|
142
|
+
`ThemeProvider` reads `useColorScheme()` from `react-native` and resolves to
|
|
143
|
+
`lightTheme` or `darkTheme` per OS setting. Pin with `theme={lightTheme}` to
|
|
144
|
+
force a scheme; pass a custom `makeTheme(...)` to override the palette.
|
|
145
|
+
|
|
146
|
+
## Verification
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pnpm --filter @startsimpli/ui-native test # 51 tests across 6 files
|
|
150
|
+
pnpm --filter @startsimpli/ui-native type-check
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Tests cover the tokens, the resolved themes, the chart math, and every style
|
|
154
|
+
builder (dashboard composites + primitives). Component rendering is exercised
|
|
155
|
+
end-to-end by `examples/mobile-rn/App.tsx` (Expo iOS/Android/web).
|
|
156
|
+
|
|
157
|
+
**Browser/device verification is not optional** for visual changes — drive the
|
|
158
|
+
Expo demo on simulator/device (`pnpm --filter mobile-rn ios|android|web`) and
|
|
159
|
+
report what you actually saw. Type-checks and unit tests do not prove a
|
|
160
|
+
primitive renders. See the MCP-default rule in
|
|
161
|
+
`/Users/qosha/Repos/start-simpli/CLAUDE.md` (rule 10).
|
|
162
|
+
|
|
163
|
+
## Shared-package policy
|
|
164
|
+
|
|
165
|
+
Per `CLAUDE.md` rule 9, every RN UI primitive that another app might
|
|
166
|
+
plausibly want belongs here — **not** in `apps/*/src`. If you're about to
|
|
167
|
+
build a Pressable wrapper, a card variant, a chart, or a token-aware style
|
|
168
|
+
helper in an app, extend this package instead. The web equivalent lives in
|
|
169
|
+
[`@startsimpli/ui`](../ui); keep the two in lock-step.
|
|
170
|
+
|
|
171
|
+
## Links
|
|
172
|
+
|
|
173
|
+
- Source: https://github.com/qosha1/start-simpli/tree/main/packages/ui-native
|
|
174
|
+
- Homepage: https://startsimpli.com
|
|
175
|
+
- License: MIT, see [LICENSE](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "React Native UI primitives for StartSimpli apps (View/Text/Pressable), fed by shared design tokens",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://startsimpli.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/qosha1/start-simpli.git",
|
|
10
|
+
"directory": "packages/ui-native"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/qosha1/start-simpli/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./src/index.ts",
|
|
20
|
+
"react-native": "./src/index.ts",
|
|
21
|
+
"default": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./tokens": "./src/tokens.ts",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": ">=18.0.0",
|
|
36
|
+
"react-native": ">=0.74.0",
|
|
37
|
+
"react-native-safe-area-context": ">=4.0.0",
|
|
38
|
+
"react-native-svg": ">=14.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react-native-safe-area-context": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"react-native-svg": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/react": "^19.2.14",
|
|
50
|
+
"react": "^19.2.5",
|
|
51
|
+
"react-native": "0.85.3",
|
|
52
|
+
"react-native-safe-area-context": "~5.7.0",
|
|
53
|
+
"react-native-svg": "15.15.4",
|
|
54
|
+
"typescript": "^6.0.3",
|
|
55
|
+
"vitest": "^4.1.5"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"type-check": "tsc --noEmit",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:watch": "vitest",
|
|
61
|
+
"clean": "rm -rf dist"
|
|
62
|
+
}
|
|
6
63
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { points, linePath, areaPath, donutDashes, sharedExtent, multiLinePaths } from '../charts-math';
|
|
3
|
+
|
|
4
|
+
describe('points', () => {
|
|
5
|
+
it('spreads x across the padded width and maps min->bottom, max->top', () => {
|
|
6
|
+
const box = { width: 100, height: 50, pad: 0 };
|
|
7
|
+
const pts = points([0, 10], box);
|
|
8
|
+
expect(pts[0]).toEqual([0, 50]); // first x=0, min value -> bottom (y=height)
|
|
9
|
+
expect(pts[1]).toEqual([100, 0]); // last x=width, max value -> top (y=0)
|
|
10
|
+
});
|
|
11
|
+
it('centers a single point and never divides by zero on a flat series', () => {
|
|
12
|
+
expect(points([5], { width: 80, height: 20 })[0][0]).toBe(40);
|
|
13
|
+
const flat = points([3, 3, 3], { width: 90, height: 30, pad: 0 });
|
|
14
|
+
expect(flat.every(([, y]) => Number.isFinite(y))).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('paths', () => {
|
|
19
|
+
it('linePath starts with M and has a point per value', () => {
|
|
20
|
+
const d = linePath([1, 2, 3], { width: 60, height: 20 });
|
|
21
|
+
expect(d.startsWith('M')).toBe(true);
|
|
22
|
+
expect((d.match(/[ML]/g) ?? []).length).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
it('areaPath closes back to the baseline', () => {
|
|
25
|
+
const d = areaPath([1, 2], { width: 60, height: 20, pad: 0 });
|
|
26
|
+
expect(d.endsWith('Z')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('donutDashes', () => {
|
|
31
|
+
it('splits the circumference proportionally and accumulates offsets', () => {
|
|
32
|
+
const c = 100;
|
|
33
|
+
const d = donutDashes([{ value: 1, color: 'a' }, { value: 3, color: 'b' }], c, 0);
|
|
34
|
+
expect(d[0].fraction).toBeCloseTo(0.25);
|
|
35
|
+
expect(d[0].dash).toBeCloseTo(25);
|
|
36
|
+
expect(d[1].dash).toBeCloseTo(75);
|
|
37
|
+
expect(d[0].offset).toBe(-0); // first slice starts at 0
|
|
38
|
+
expect(d[1].offset).toBeCloseTo(-25); // second starts where first ended
|
|
39
|
+
});
|
|
40
|
+
it('handles all-zero input without NaN', () => {
|
|
41
|
+
const d = donutDashes([{ value: 0, color: 'a' }], 100);
|
|
42
|
+
expect(Number.isFinite(d[0].dash)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('shared-domain scaling (multi-series compare)', () => {
|
|
47
|
+
it('points honors an explicit domain so flat series sit by their absolute level', () => {
|
|
48
|
+
const box = { width: 100, height: 50, pad: 0 };
|
|
49
|
+
// value 5 in domain [0,10] → halfway up (y = height/2)
|
|
50
|
+
expect(points([5, 5], box, [0, 10])[0][1]).toBeCloseTo(25);
|
|
51
|
+
// value 10 in the same domain → top
|
|
52
|
+
expect(points([10], box, [0, 10])[0][1]).toBeCloseTo(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('sharedExtent spans the min and max across every series', () => {
|
|
56
|
+
expect(sharedExtent([[1, 4], [2, 9], [0, 3]])).toEqual([0, 9]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('sharedExtent is finite on empty input', () => {
|
|
60
|
+
const [lo, hi] = sharedExtent([]);
|
|
61
|
+
expect(Number.isFinite(lo) && Number.isFinite(hi)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('multiLinePaths returns one path per series, all on the same scale', () => {
|
|
65
|
+
const box = { width: 100, height: 50, pad: 0 };
|
|
66
|
+
const paths = multiLinePaths([[0, 10], [5, 5]], box);
|
|
67
|
+
expect(paths.length).toBe(2);
|
|
68
|
+
expect(paths.every((d) => d.startsWith('M'))).toBe(true);
|
|
69
|
+
// series B is flat at 5 → both endpoints at the same y (mid of shared [0,10])
|
|
70
|
+
const yB = paths[1].match(/M[\d.]+ ([\d.]+)/)?.[1];
|
|
71
|
+
expect(Number(yB)).toBeCloseTo(25);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { lightTheme } from '../theme';
|
|
3
|
+
import {
|
|
4
|
+
avatarStyle,
|
|
5
|
+
avatarTextStyle,
|
|
6
|
+
statTileStyle,
|
|
7
|
+
statValueStyle,
|
|
8
|
+
statDeltaColor,
|
|
9
|
+
listRowStyle,
|
|
10
|
+
listRowTitleStyle,
|
|
11
|
+
tabBarStyle,
|
|
12
|
+
tabItemStyle,
|
|
13
|
+
tabLabelStyle,
|
|
14
|
+
shareBarTrackStyle,
|
|
15
|
+
chipStyle,
|
|
16
|
+
chipTextStyle,
|
|
17
|
+
timelineDotStyle,
|
|
18
|
+
timelineConnectorStyle,
|
|
19
|
+
} from '../styles';
|
|
20
|
+
|
|
21
|
+
const t = lightTheme;
|
|
22
|
+
|
|
23
|
+
describe('avatar', () => {
|
|
24
|
+
it('is a circular sized surface; md = 44px', () => {
|
|
25
|
+
const s = avatarStyle(t, 'md');
|
|
26
|
+
expect(s.width).toBe(44);
|
|
27
|
+
expect(s.borderRadius).toBe(t.radii.full);
|
|
28
|
+
expect(s.backgroundColor).toBe(t.colors.secondary);
|
|
29
|
+
});
|
|
30
|
+
it('honors a custom color and text color', () => {
|
|
31
|
+
expect(avatarStyle(t, 'sm', '#abc').backgroundColor).toBe('#abc');
|
|
32
|
+
expect(avatarTextStyle(t, 'lg', '#fff').color).toBe('#fff');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('statTile', () => {
|
|
37
|
+
it('value is a 3xl extrabold display number; delta colors track direction', () => {
|
|
38
|
+
expect(statValueStyle(t).fontSize).toBe(t.fontSizes['3xl']);
|
|
39
|
+
expect(statValueStyle(t).fontWeight).toBe(t.fontWeights.extrabold);
|
|
40
|
+
expect(statTileStyle(t).borderColor).toBe(t.colors.border);
|
|
41
|
+
expect(statDeltaColor(t, 'up')).toBe(t.colors.primary);
|
|
42
|
+
expect(statDeltaColor(t, 'down')).toBe(t.colors.destructive);
|
|
43
|
+
expect(statDeltaColor(t, 'neutral')).toBe(t.colors.mutedForeground);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('listRow', () => {
|
|
48
|
+
it('is a horizontal row; title is medium-weight foreground', () => {
|
|
49
|
+
expect(listRowStyle(t).flexDirection).toBe('row');
|
|
50
|
+
expect(listRowTitleStyle(t).fontWeight).toBe(t.fontWeights.medium);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('shareBar', () => {
|
|
55
|
+
it('is a rounded, clipped horizontal track', () => {
|
|
56
|
+
const s = shareBarTrackStyle(t, 10);
|
|
57
|
+
expect(s.flexDirection).toBe('row');
|
|
58
|
+
expect(s.height).toBe(10);
|
|
59
|
+
expect(s.overflow).toBe('hidden');
|
|
60
|
+
expect(s.borderRadius).toBe(t.radii.full);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('chip', () => {
|
|
65
|
+
it('selected fills primary; unselected is transparent with a border', () => {
|
|
66
|
+
expect(chipStyle(t, true).backgroundColor).toBe(t.colors.primary);
|
|
67
|
+
expect(chipTextStyle(t, true).color).toBe(t.colors.primaryForeground);
|
|
68
|
+
expect(chipStyle(t, false).backgroundColor).toBe('transparent');
|
|
69
|
+
expect(chipStyle(t, false).borderColor).toBe(t.colors.border);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('timeline', () => {
|
|
74
|
+
it('dot defaults to primary and is round; connector uses the border color', () => {
|
|
75
|
+
expect(timelineDotStyle(t).backgroundColor).toBe(t.colors.primary);
|
|
76
|
+
expect(timelineDotStyle(t, '#abc').backgroundColor).toBe('#abc');
|
|
77
|
+
expect(timelineDotStyle(t).borderRadius).toBe(t.radii.full);
|
|
78
|
+
expect(timelineConnectorStyle(t).backgroundColor).toBe(t.colors.border);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('tabBar', () => {
|
|
83
|
+
it('has a top border; items flex equally', () => {
|
|
84
|
+
expect(tabBarStyle(t).borderTopWidth).toBe(1);
|
|
85
|
+
expect(tabItemStyle(t).flex).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
it('active label is primary + semibold, inactive is muted', () => {
|
|
88
|
+
expect(tabLabelStyle(t, true).color).toBe(t.colors.primary);
|
|
89
|
+
expect(tabLabelStyle(t, true).fontWeight).toBe(t.fontWeights.semibold);
|
|
90
|
+
expect(tabLabelStyle(t, false).color).toBe(t.colors.mutedForeground);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { lightTheme } from '../theme';
|
|
3
|
+
import {
|
|
4
|
+
buttonContainerStyle,
|
|
5
|
+
buttonTextStyle,
|
|
6
|
+
badgeStyle,
|
|
7
|
+
badgeTextStyle,
|
|
8
|
+
inputStyle,
|
|
9
|
+
labelStyle,
|
|
10
|
+
separatorStyle,
|
|
11
|
+
checkboxStyle,
|
|
12
|
+
progressTrackStyle,
|
|
13
|
+
progressFillStyle,
|
|
14
|
+
alertStyle,
|
|
15
|
+
alertTitleStyle,
|
|
16
|
+
skeletonStyle,
|
|
17
|
+
cardTitleStyle,
|
|
18
|
+
cardDescriptionStyle,
|
|
19
|
+
} from '../styles';
|
|
20
|
+
|
|
21
|
+
const t = lightTheme;
|
|
22
|
+
|
|
23
|
+
describe('button: ghost + link variants', () => {
|
|
24
|
+
it('ghost is transparent with foreground text and no border', () => {
|
|
25
|
+
const c = buttonContainerStyle(t, 'ghost', 'md');
|
|
26
|
+
expect(c.backgroundColor).toBe('transparent');
|
|
27
|
+
expect(c.borderWidth).toBeUndefined();
|
|
28
|
+
expect(buttonTextStyle(t, 'ghost', 'md').color).toBe(t.colors.foreground);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('link is transparent with primary-colored text', () => {
|
|
32
|
+
expect(buttonContainerStyle(t, 'link', 'md').backgroundColor).toBe('transparent');
|
|
33
|
+
expect(buttonTextStyle(t, 'link', 'md').color).toBe(t.colors.primary);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('badge', () => {
|
|
38
|
+
it('default fills primary; text is on-primary', () => {
|
|
39
|
+
expect(badgeStyle(t, 'default').backgroundColor).toBe(t.colors.primary);
|
|
40
|
+
expect(badgeTextStyle(t, 'default').color).toBe(t.colors.primaryForeground);
|
|
41
|
+
});
|
|
42
|
+
it('destructive fills destructive', () => {
|
|
43
|
+
expect(badgeStyle(t, 'destructive').backgroundColor).toBe(t.colors.destructive);
|
|
44
|
+
});
|
|
45
|
+
it('outline is transparent with a border', () => {
|
|
46
|
+
const s = badgeStyle(t, 'outline');
|
|
47
|
+
expect(s.backgroundColor).toBe('transparent');
|
|
48
|
+
expect(s.borderWidth).toBe(1);
|
|
49
|
+
expect(badgeTextStyle(t, 'outline').color).toBe(t.colors.foreground);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('input + label', () => {
|
|
54
|
+
it('input uses the input border by default and destructive when invalid', () => {
|
|
55
|
+
expect(inputStyle(t).borderColor).toBe(t.colors.input);
|
|
56
|
+
expect(inputStyle(t).borderWidth).toBe(1);
|
|
57
|
+
expect(inputStyle(t, { invalid: true }).borderColor).toBe(t.colors.destructive);
|
|
58
|
+
});
|
|
59
|
+
it('label is medium-weight foreground text', () => {
|
|
60
|
+
const l = labelStyle(t);
|
|
61
|
+
expect(l.color).toBe(t.colors.foreground);
|
|
62
|
+
expect(l.fontWeight).toBe(t.fontWeights.medium);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('separator', () => {
|
|
67
|
+
it('horizontal is a 1px full-width border-colored rule', () => {
|
|
68
|
+
const s = separatorStyle(t, 'horizontal');
|
|
69
|
+
expect(s.height).toBe(1);
|
|
70
|
+
expect(s.backgroundColor).toBe(t.colors.border);
|
|
71
|
+
});
|
|
72
|
+
it('vertical is a 1px-wide rule', () => {
|
|
73
|
+
expect(separatorStyle(t, 'vertical').width).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('checkbox', () => {
|
|
78
|
+
it('checked fills + borders with primary; unchecked is transparent', () => {
|
|
79
|
+
const on = checkboxStyle(t, true);
|
|
80
|
+
expect(on.backgroundColor).toBe(t.colors.primary);
|
|
81
|
+
expect(on.borderColor).toBe(t.colors.primary);
|
|
82
|
+
expect(checkboxStyle(t, false).backgroundColor).toBe('transparent');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('progress', () => {
|
|
87
|
+
it('track is a muted pill; fill is primary and width tracks value', () => {
|
|
88
|
+
expect(progressTrackStyle(t).backgroundColor).toBe(t.colors.muted);
|
|
89
|
+
expect(progressFillStyle(t, 50).width).toBe('50%');
|
|
90
|
+
expect(progressFillStyle(t, 50).backgroundColor).toBe(t.colors.primary);
|
|
91
|
+
});
|
|
92
|
+
it('clamps value to [0, 100]', () => {
|
|
93
|
+
expect(progressFillStyle(t, 150).width).toBe('100%');
|
|
94
|
+
expect(progressFillStyle(t, -5).width).toBe('0%');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('alert', () => {
|
|
99
|
+
it('default vs destructive variants color the border + title', () => {
|
|
100
|
+
expect(alertStyle(t, 'destructive').borderColor).toBe(t.colors.destructive);
|
|
101
|
+
expect(alertTitleStyle(t, 'destructive').color).toBe(t.colors.destructive);
|
|
102
|
+
expect(alertTitleStyle(t, 'default').color).toBe(t.colors.foreground);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('skeleton + card text', () => {
|
|
107
|
+
it('skeleton is a muted rounded block', () => {
|
|
108
|
+
expect(skeletonStyle(t).backgroundColor).toBe(t.colors.muted);
|
|
109
|
+
});
|
|
110
|
+
it('card title is semibold; description is muted', () => {
|
|
111
|
+
expect(cardTitleStyle(t).fontWeight).toBe(t.fontWeights.semibold);
|
|
112
|
+
expect(cardDescriptionStyle(t).color).toBe(t.colors.mutedForeground);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { lightTheme } from '../theme';
|
|
3
|
+
import {
|
|
4
|
+
buttonContainerStyle,
|
|
5
|
+
buttonTextStyle,
|
|
6
|
+
cardStyle,
|
|
7
|
+
textStyle,
|
|
8
|
+
badgeStyle,
|
|
9
|
+
badgeTextStyle,
|
|
10
|
+
} from '../styles';
|
|
11
|
+
|
|
12
|
+
describe('textStyle display variant', () => {
|
|
13
|
+
it('is a large, extra-bold variant suitable for hero numerals', () => {
|
|
14
|
+
const s = textStyle(lightTheme, 'display');
|
|
15
|
+
expect(s.fontSize ?? 0).toBeGreaterThanOrEqual(40);
|
|
16
|
+
expect(Number(s.fontWeight ?? '0')).toBeGreaterThanOrEqual(800);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('buttonContainerStyle touch targets', () => {
|
|
21
|
+
it('md (default) buttons meet the 44px touch-target guidance', () => {
|
|
22
|
+
expect(buttonContainerStyle(lightTheme, 'primary', 'md').minHeight).toBeGreaterThanOrEqual(44);
|
|
23
|
+
});
|
|
24
|
+
it('lg buttons are larger still', () => {
|
|
25
|
+
expect(buttonContainerStyle(lightTheme, 'primary', 'lg').minHeight).toBeGreaterThanOrEqual(48);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('badgeStyle semantic variants', () => {
|
|
30
|
+
it('success uses the success color with its foreground', () => {
|
|
31
|
+
expect(badgeStyle(lightTheme, 'success').backgroundColor).toBe(lightTheme.colors.success);
|
|
32
|
+
expect(badgeTextStyle(lightTheme, 'success').color).toBe(lightTheme.colors.successForeground);
|
|
33
|
+
});
|
|
34
|
+
it('warning uses the warning color with its foreground', () => {
|
|
35
|
+
expect(badgeStyle(lightTheme, 'warning').backgroundColor).toBe(lightTheme.colors.warning);
|
|
36
|
+
expect(badgeTextStyle(lightTheme, 'warning').color).toBe(lightTheme.colors.warningForeground);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('buttonContainerStyle', () => {
|
|
41
|
+
it('fills with the primary color and uses the md radius', () => {
|
|
42
|
+
const s = buttonContainerStyle(lightTheme, 'primary', 'md');
|
|
43
|
+
expect(s.backgroundColor).toBe(lightTheme.colors.primary);
|
|
44
|
+
expect(s.borderRadius).toBe(lightTheme.radii.md);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders the destructive variant with the destructive color', () => {
|
|
48
|
+
expect(buttonContainerStyle(lightTheme, 'destructive', 'md').backgroundColor).toBe(
|
|
49
|
+
lightTheme.colors.destructive
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders outline as a transparent, bordered button', () => {
|
|
54
|
+
const s = buttonContainerStyle(lightTheme, 'outline', 'md');
|
|
55
|
+
expect(s.backgroundColor).toBe('transparent');
|
|
56
|
+
expect(s.borderWidth).toBe(1);
|
|
57
|
+
expect(s.borderColor).toBe(lightTheme.colors.border);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('scales vertical padding with size', () => {
|
|
61
|
+
const sm = buttonContainerStyle(lightTheme, 'primary', 'sm');
|
|
62
|
+
const lg = buttonContainerStyle(lightTheme, 'primary', 'lg');
|
|
63
|
+
expect(Number(sm.paddingVertical)).toBeLessThan(Number(lg.paddingVertical));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('buttonTextStyle', () => {
|
|
68
|
+
it('uses the on-primary foreground for the primary variant', () => {
|
|
69
|
+
expect(buttonTextStyle(lightTheme, 'primary', 'md').color).toBe(
|
|
70
|
+
lightTheme.colors.primaryForeground
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('uses the foreground color for the outline variant', () => {
|
|
75
|
+
expect(buttonTextStyle(lightTheme, 'outline', 'md').color).toBe(lightTheme.colors.foreground);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('cardStyle', () => {
|
|
80
|
+
it('uses the card surface, lg radius and a hairline border', () => {
|
|
81
|
+
const s = cardStyle(lightTheme);
|
|
82
|
+
expect(s.backgroundColor).toBe(lightTheme.colors.card);
|
|
83
|
+
expect(s.borderRadius).toBe(lightTheme.radii.lg);
|
|
84
|
+
expect(s.borderWidth).toBe(1);
|
|
85
|
+
expect(s.borderColor).toBe(lightTheme.colors.border);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('textStyle', () => {
|
|
90
|
+
it('makes headings larger and heavier than body', () => {
|
|
91
|
+
const heading = textStyle(lightTheme, 'heading');
|
|
92
|
+
const body = textStyle(lightTheme, 'body');
|
|
93
|
+
expect(Number(heading.fontSize)).toBeGreaterThan(Number(body.fontSize));
|
|
94
|
+
expect(heading.fontWeight).toBe(lightTheme.fontWeights.bold);
|
|
95
|
+
expect(heading.color).toBe(lightTheme.colors.foreground);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders muted text with the muted foreground color', () => {
|
|
99
|
+
expect(textStyle(lightTheme, 'muted').color).toBe(lightTheme.colors.mutedForeground);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { makeTheme, lightTheme, darkTheme } from '../theme';
|
|
3
|
+
|
|
4
|
+
describe('theme', () => {
|
|
5
|
+
it('resolves palette channels into RN hsl() colors for the light scheme', () => {
|
|
6
|
+
const t = makeTheme('light');
|
|
7
|
+
expect(t.colors.primary).toBe('hsl(221.2, 83.2%, 53.3%)');
|
|
8
|
+
expect(t.colors.background).toBe('hsl(0, 0%, 100%)');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('resolves the dark scheme', () => {
|
|
12
|
+
const t = makeTheme('dark');
|
|
13
|
+
expect(t.colors.background).toBe('hsl(222.2, 84%, 4.9%)');
|
|
14
|
+
expect(t.colors.primary).toBe('hsl(217.2, 91.2%, 59.8%)');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('carries the shared shape/spacing/type scales', () => {
|
|
18
|
+
expect(lightTheme.radii.md).toBe(8);
|
|
19
|
+
expect(lightTheme.spacing.md).toBe(12);
|
|
20
|
+
expect(lightTheme.fontSizes.base).toBe(16);
|
|
21
|
+
expect(darkTheme.radii.md).toBe(8); // shape is scheme-independent
|
|
22
|
+
});
|
|
23
|
+
});
|