@startsimpli/ui-native 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +172 -2
  2. package/package.json +50 -4
  3. package/src/__tests__/charts-math.test.ts +73 -0
  4. package/src/__tests__/styles-dashboard.test.ts +92 -0
  5. package/src/__tests__/styles-primitives.test.ts +114 -0
  6. package/src/__tests__/styles.test.ts +101 -0
  7. package/src/__tests__/theme.test.ts +23 -0
  8. package/src/__tests__/tokens.test.ts +33 -0
  9. package/src/charts-math.ts +106 -0
  10. package/src/components/AccentContext.tsx +22 -0
  11. package/src/components/Alert.tsx +27 -0
  12. package/src/components/Avatar.tsx +42 -0
  13. package/src/components/Badge.tsx +18 -0
  14. package/src/components/Button.tsx +40 -0
  15. package/src/components/Card.tsx +30 -0
  16. package/src/components/Checkbox.tsx +31 -0
  17. package/src/components/Chip.tsx +26 -0
  18. package/src/components/CompareChart.tsx +56 -0
  19. package/src/components/DataTable.tsx +89 -0
  20. package/src/components/DemographicBars.tsx +50 -0
  21. package/src/components/DonutChart.tsx +57 -0
  22. package/src/components/DonutLegend.tsx +49 -0
  23. package/src/components/DualRangeControl.tsx +31 -0
  24. package/src/components/EmptyState.tsx +63 -0
  25. package/src/components/ErrorBoundary.tsx +52 -0
  26. package/src/components/Flag.tsx +26 -0
  27. package/src/components/GradientBox.tsx +48 -0
  28. package/src/components/HeroBanner.tsx +160 -0
  29. package/src/components/Input.tsx +33 -0
  30. package/src/components/Label.tsx +9 -0
  31. package/src/components/ListRow.tsx +44 -0
  32. package/src/components/MetricRow.tsx +48 -0
  33. package/src/components/MultiLineLegend.tsx +30 -0
  34. package/src/components/Progress.tsx +18 -0
  35. package/src/components/Screen.tsx +38 -0
  36. package/src/components/SegmentedControl.tsx +31 -0
  37. package/src/components/Separator.tsx +13 -0
  38. package/src/components/ShareBar.tsx +32 -0
  39. package/src/components/Skeleton.tsx +29 -0
  40. package/src/components/SkeletonList.tsx +23 -0
  41. package/src/components/Sparkline.tsx +38 -0
  42. package/src/components/Spinner.tsx +8 -0
  43. package/src/components/StatTile.tsx +69 -0
  44. package/src/components/Switch.tsx +17 -0
  45. package/src/components/TabBar.tsx +50 -0
  46. package/src/components/Text.tsx +13 -0
  47. package/src/components/ThemeProvider.tsx +25 -0
  48. package/src/components/Timeline.tsx +47 -0
  49. package/src/components/TrendCard.tsx +39 -0
  50. package/src/components/TrendChart.tsx +68 -0
  51. package/src/index.ts +64 -0
  52. package/src/styles.ts +479 -0
  53. package/src/theme.ts +46 -0
  54. package/src/tokens.ts +145 -0
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # @startsimpli/ui-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ## License
172
+
173
+ Private package for the StartSimpli monorepo.
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@startsimpli/ui-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "React Native UI primitives for StartSimpli apps (View/Text/Pressable), fed by shared design tokens",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "react-native": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./tokens": "./src/tokens.ts",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "type-check": "tsc --noEmit",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "clean": "rm -rf dist"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18.0.0",
31
+ "react-native": ">=0.74.0",
32
+ "react-native-safe-area-context": ">=4.0.0",
33
+ "react-native-svg": ">=14.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "react-native-safe-area-context": {
37
+ "optional": true
38
+ },
39
+ "react-native-svg": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^19.2.14",
45
+ "react": "^19.2.5",
46
+ "react-native": "0.85.3",
47
+ "react-native-safe-area-context": "~5.7.0",
48
+ "react-native-svg": "15.15.4",
49
+ "typescript": "^6.0.3",
50
+ "vitest": "^4.1.5"
51
+ }
52
+ }
@@ -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
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { palette, hsl, radii, spacing, fontSizes, fontWeights, durations } from '../tokens';
3
+
4
+ describe('tokens', () => {
5
+ it('exposes HSL channel values that match packages/ui/theme/contract.css', () => {
6
+ // Single source of truth with the web CSS variables — keep these in sync.
7
+ expect(palette.light.primary).toBe('221.2 83.2% 53.3%');
8
+ expect(palette.light.background).toBe('0 0% 100%');
9
+ expect(palette.dark.background).toBe('222.2 84% 4.9%');
10
+ expect(palette.dark.primary).toBe('217.2 91.2% 59.8%');
11
+ });
12
+
13
+ it('hsl() converts channel strings into a React Native-parseable color', () => {
14
+ expect(hsl('221.2 83.2% 53.3%')).toBe('hsl(221.2, 83.2%, 53.3%)');
15
+ expect(hsl('0 0% 100%')).toBe('hsl(0, 0%, 100%)');
16
+ });
17
+
18
+ it('derives radii from contract.css (--radius: 0.5rem = 8px)', () => {
19
+ expect(radii.sm).toBe(4);
20
+ expect(radii.md).toBe(8);
21
+ expect(radii.lg).toBe(12);
22
+ expect(radii.xl).toBe(16);
23
+ expect(radii.full).toBe(9999);
24
+ });
25
+
26
+ it('provides numeric spacing / font scales for StyleSheet', () => {
27
+ expect(spacing.md).toBe(12);
28
+ expect(spacing.xs).toBeLessThan(spacing.xl);
29
+ expect(fontSizes.base).toBe(16);
30
+ expect(fontWeights.bold).toBe('700');
31
+ expect(durations.normal).toBe(200);
32
+ });
33
+ });