@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -2
  3. package/package.json +60 -3
  4. package/src/__tests__/charts-math.test.ts +73 -0
  5. package/src/__tests__/styles-dashboard.test.ts +92 -0
  6. package/src/__tests__/styles-primitives.test.ts +114 -0
  7. package/src/__tests__/styles.test.ts +101 -0
  8. package/src/__tests__/theme.test.ts +23 -0
  9. package/src/__tests__/tokens.test.ts +33 -0
  10. package/src/charts-math.ts +106 -0
  11. package/src/components/AccentContext.tsx +22 -0
  12. package/src/components/Alert.tsx +27 -0
  13. package/src/components/Avatar.tsx +42 -0
  14. package/src/components/Badge.tsx +18 -0
  15. package/src/components/Button.tsx +40 -0
  16. package/src/components/Card.tsx +30 -0
  17. package/src/components/Checkbox.tsx +31 -0
  18. package/src/components/Chip.tsx +26 -0
  19. package/src/components/CompareChart.tsx +56 -0
  20. package/src/components/DataTable.tsx +89 -0
  21. package/src/components/DemographicBars.tsx +50 -0
  22. package/src/components/DonutChart.tsx +57 -0
  23. package/src/components/DonutLegend.tsx +49 -0
  24. package/src/components/DualRangeControl.tsx +31 -0
  25. package/src/components/EmptyState.tsx +63 -0
  26. package/src/components/ErrorBoundary.tsx +52 -0
  27. package/src/components/Flag.tsx +26 -0
  28. package/src/components/GradientBox.tsx +48 -0
  29. package/src/components/HeroBanner.tsx +160 -0
  30. package/src/components/Input.tsx +33 -0
  31. package/src/components/Label.tsx +9 -0
  32. package/src/components/ListRow.tsx +44 -0
  33. package/src/components/MetricRow.tsx +48 -0
  34. package/src/components/MultiLineLegend.tsx +30 -0
  35. package/src/components/Progress.tsx +18 -0
  36. package/src/components/Screen.tsx +38 -0
  37. package/src/components/SegmentedControl.tsx +31 -0
  38. package/src/components/Separator.tsx +13 -0
  39. package/src/components/ShareBar.tsx +32 -0
  40. package/src/components/Skeleton.tsx +29 -0
  41. package/src/components/SkeletonList.tsx +23 -0
  42. package/src/components/Sparkline.tsx +38 -0
  43. package/src/components/Spinner.tsx +8 -0
  44. package/src/components/StatTile.tsx +69 -0
  45. package/src/components/Switch.tsx +17 -0
  46. package/src/components/TabBar.tsx +50 -0
  47. package/src/components/Text.tsx +13 -0
  48. package/src/components/ThemeProvider.tsx +25 -0
  49. package/src/components/Timeline.tsx +47 -0
  50. package/src/components/TrendCard.tsx +39 -0
  51. package/src/components/TrendChart.tsx +68 -0
  52. package/src/index.ts +64 -0
  53. package/src/styles.ts +479 -0
  54. package/src/theme.ts +46 -0
  55. package/src/tokens.ts +145 -0
package/src/styles.ts ADDED
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Pure style builders for the primitives.
3
+ *
4
+ * Kept separate from the components (and free of any react-native *value* import
5
+ * — only the ViewStyle/TextStyle types) so the styling logic is unit-testable in
6
+ * plain Node without a React Native renderer.
7
+ */
8
+
9
+ import type { TextStyle, ViewStyle } from 'react-native';
10
+ import type { Theme } from './theme';
11
+
12
+ /* ----------------------------------------------------------------- Button -- */
13
+
14
+ export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link';
15
+ export type ButtonSize = 'sm' | 'md' | 'lg';
16
+
17
+ const BUTTON_PADDING: Record<ButtonSize, { paddingVertical: number; paddingHorizontal: number }> = {
18
+ sm: { paddingVertical: 4, paddingHorizontal: 8 },
19
+ md: { paddingVertical: 8, paddingHorizontal: 12 },
20
+ lg: { paddingVertical: 12, paddingHorizontal: 16 },
21
+ };
22
+
23
+ // Minimum touch-target heights. md (the default) meets the 44px WCAG / Apple
24
+ // HIG / Material guidance for primary controls; sm stays tighter for toolbar
25
+ // chips where layout density matters; lg is slightly more prominent.
26
+ const BUTTON_MIN_HEIGHT: Record<ButtonSize, number> = { sm: 36, md: 44, lg: 52 };
27
+
28
+ const BUTTON_FONT_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 18 };
29
+
30
+ export function buttonContainerStyle(
31
+ theme: Theme,
32
+ variant: ButtonVariant,
33
+ size: ButtonSize
34
+ ): ViewStyle {
35
+ const base: ViewStyle = {
36
+ flexDirection: 'row',
37
+ alignItems: 'center',
38
+ justifyContent: 'center',
39
+ borderRadius: theme.radii.md,
40
+ minHeight: BUTTON_MIN_HEIGHT[size],
41
+ ...BUTTON_PADDING[size],
42
+ };
43
+
44
+ switch (variant) {
45
+ case 'outline':
46
+ return { ...base, backgroundColor: 'transparent', borderWidth: 1, borderColor: theme.colors.border };
47
+ case 'ghost':
48
+ case 'link':
49
+ return { ...base, backgroundColor: 'transparent' };
50
+ case 'secondary':
51
+ return { ...base, backgroundColor: theme.colors.secondary };
52
+ case 'destructive':
53
+ return { ...base, backgroundColor: theme.colors.destructive };
54
+ case 'primary':
55
+ default:
56
+ return { ...base, backgroundColor: theme.colors.primary };
57
+ }
58
+ }
59
+
60
+ export function buttonTextStyle(theme: Theme, variant: ButtonVariant, size: ButtonSize): TextStyle {
61
+ const color: Record<ButtonVariant, string> = {
62
+ primary: theme.colors.primaryForeground,
63
+ secondary: theme.colors.secondaryForeground,
64
+ destructive: theme.colors.destructiveForeground,
65
+ outline: theme.colors.foreground,
66
+ ghost: theme.colors.foreground,
67
+ link: theme.colors.primary,
68
+ };
69
+ return {
70
+ color: color[variant],
71
+ fontSize: BUTTON_FONT_SIZE[size],
72
+ fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'],
73
+ textDecorationLine: variant === 'link' ? 'underline' : 'none',
74
+ };
75
+ }
76
+
77
+ /* ------------------------------------------------------------------ Badge -- */
78
+
79
+ export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'outline';
80
+
81
+ export function badgeStyle(theme: Theme, variant: BadgeVariant): ViewStyle {
82
+ const base: ViewStyle = {
83
+ alignSelf: 'flex-start',
84
+ flexDirection: 'row',
85
+ alignItems: 'center',
86
+ paddingHorizontal: theme.spacing.sm,
87
+ paddingVertical: 2,
88
+ borderRadius: theme.radii.full,
89
+ };
90
+ switch (variant) {
91
+ case 'outline':
92
+ return { ...base, backgroundColor: 'transparent', borderWidth: 1, borderColor: theme.colors.border };
93
+ case 'secondary':
94
+ return { ...base, backgroundColor: theme.colors.secondary };
95
+ case 'destructive':
96
+ return { ...base, backgroundColor: theme.colors.destructive };
97
+ case 'success':
98
+ return { ...base, backgroundColor: theme.colors.success };
99
+ case 'warning':
100
+ return { ...base, backgroundColor: theme.colors.warning };
101
+ case 'default':
102
+ default:
103
+ return { ...base, backgroundColor: theme.colors.primary };
104
+ }
105
+ }
106
+
107
+ export function badgeTextStyle(theme: Theme, variant: BadgeVariant): TextStyle {
108
+ const color: Record<BadgeVariant, string> = {
109
+ default: theme.colors.primaryForeground,
110
+ secondary: theme.colors.secondaryForeground,
111
+ destructive: theme.colors.destructiveForeground,
112
+ success: theme.colors.successForeground,
113
+ warning: theme.colors.warningForeground,
114
+ outline: theme.colors.foreground,
115
+ };
116
+ return { color: color[variant], fontSize: theme.fontSizes.xs, fontWeight: theme.fontWeights.medium as TextStyle['fontWeight'] };
117
+ }
118
+
119
+ /* --------------------------------------------------------- Input + Label -- */
120
+
121
+ export function inputStyle(theme: Theme, opts: { invalid?: boolean } = {}): TextStyle {
122
+ return {
123
+ borderWidth: 1,
124
+ borderColor: opts.invalid ? theme.colors.destructive : theme.colors.input,
125
+ borderRadius: theme.radii.md,
126
+ paddingHorizontal: theme.spacing.md,
127
+ paddingVertical: theme.spacing.sm,
128
+ fontSize: theme.fontSizes.base,
129
+ color: theme.colors.foreground,
130
+ backgroundColor: theme.colors.background,
131
+ };
132
+ }
133
+
134
+ export function labelStyle(theme: Theme): TextStyle {
135
+ return {
136
+ fontSize: theme.fontSizes.sm,
137
+ fontWeight: theme.fontWeights.medium as TextStyle['fontWeight'],
138
+ color: theme.colors.foreground,
139
+ };
140
+ }
141
+
142
+ /* -------------------------------------------------------------- Separator -- */
143
+
144
+ export function separatorStyle(theme: Theme, orientation: 'horizontal' | 'vertical'): ViewStyle {
145
+ return orientation === 'vertical'
146
+ ? { width: 1, alignSelf: 'stretch', backgroundColor: theme.colors.border }
147
+ : { height: 1, width: '100%', backgroundColor: theme.colors.border };
148
+ }
149
+
150
+ /* --------------------------------------------------------------- Checkbox -- */
151
+
152
+ export function checkboxStyle(theme: Theme, checked: boolean): ViewStyle {
153
+ return {
154
+ width: 22,
155
+ height: 22,
156
+ borderRadius: theme.radii.sm,
157
+ borderWidth: 1,
158
+ alignItems: 'center',
159
+ justifyContent: 'center',
160
+ borderColor: checked ? theme.colors.primary : theme.colors.input,
161
+ backgroundColor: checked ? theme.colors.primary : 'transparent',
162
+ };
163
+ }
164
+
165
+ /* --------------------------------------------------------------- Progress -- */
166
+
167
+ export function progressTrackStyle(theme: Theme): ViewStyle {
168
+ return {
169
+ height: 8,
170
+ borderRadius: theme.radii.full,
171
+ backgroundColor: theme.colors.muted,
172
+ overflow: 'hidden',
173
+ };
174
+ }
175
+
176
+ export function progressFillStyle(theme: Theme, value: number): ViewStyle {
177
+ const pct = Math.max(0, Math.min(100, value));
178
+ return {
179
+ height: '100%',
180
+ width: `${pct}%`,
181
+ borderRadius: theme.radii.full,
182
+ backgroundColor: theme.colors.primary,
183
+ };
184
+ }
185
+
186
+ /* ------------------------------------------------------------------ Alert -- */
187
+
188
+ export type AlertVariant = 'default' | 'destructive';
189
+
190
+ export function alertStyle(theme: Theme, variant: AlertVariant): ViewStyle {
191
+ return {
192
+ padding: theme.spacing.md,
193
+ borderRadius: theme.radii.lg,
194
+ borderWidth: 1,
195
+ backgroundColor: theme.colors.card,
196
+ borderColor: variant === 'destructive' ? theme.colors.destructive : theme.colors.border,
197
+ };
198
+ }
199
+
200
+ export function alertTitleStyle(theme: Theme, variant: AlertVariant): TextStyle {
201
+ return {
202
+ fontSize: theme.fontSizes.base,
203
+ fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'],
204
+ color: variant === 'destructive' ? theme.colors.destructive : theme.colors.foreground,
205
+ };
206
+ }
207
+
208
+ export function alertTextStyle(theme: Theme, variant: AlertVariant): TextStyle {
209
+ return {
210
+ fontSize: theme.fontSizes.sm,
211
+ color: variant === 'destructive' ? theme.colors.destructive : theme.colors.mutedForeground,
212
+ };
213
+ }
214
+
215
+ /* --------------------------------------------------------------- Skeleton -- */
216
+
217
+ export function skeletonStyle(theme: Theme): ViewStyle {
218
+ return { backgroundColor: theme.colors.muted, borderRadius: theme.radii.sm };
219
+ }
220
+
221
+ /* ----------------------------------------------------------- Card content -- */
222
+
223
+ export function cardTitleStyle(theme: Theme): TextStyle {
224
+ return {
225
+ fontSize: theme.fontSizes.lg,
226
+ fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'],
227
+ color: theme.colors.cardForeground,
228
+ };
229
+ }
230
+
231
+ export function cardDescriptionStyle(theme: Theme): TextStyle {
232
+ return { fontSize: theme.fontSizes.sm, color: theme.colors.mutedForeground };
233
+ }
234
+
235
+ /* ------------------------------------------------------------------- Text -- */
236
+
237
+ export type TextVariant = 'display' | 'heading' | 'body' | 'muted' | 'caption';
238
+
239
+ export function textStyle(theme: Theme, variant: TextVariant): TextStyle {
240
+ const f: TextStyle = { fontFamily: theme.fontFamily };
241
+ switch (variant) {
242
+ case 'display':
243
+ return {
244
+ ...f,
245
+ fontSize: theme.fontSizes['4xl'],
246
+ fontWeight: theme.fontWeights.extrabold as TextStyle['fontWeight'],
247
+ color: theme.colors.foreground,
248
+ letterSpacing: -1,
249
+ };
250
+ case 'heading':
251
+ return {
252
+ ...f,
253
+ fontSize: theme.fontSizes.xl,
254
+ fontWeight: theme.fontWeights.bold as TextStyle['fontWeight'],
255
+ color: theme.colors.foreground,
256
+ };
257
+ case 'muted':
258
+ return {
259
+ ...f,
260
+ fontSize: theme.fontSizes.sm,
261
+ fontWeight: theme.fontWeights.normal as TextStyle['fontWeight'],
262
+ color: theme.colors.mutedForeground,
263
+ };
264
+ case 'caption':
265
+ return {
266
+ ...f,
267
+ fontSize: theme.fontSizes.xs,
268
+ fontWeight: theme.fontWeights.normal as TextStyle['fontWeight'],
269
+ color: theme.colors.mutedForeground,
270
+ };
271
+ case 'body':
272
+ default:
273
+ return {
274
+ ...f,
275
+ fontSize: theme.fontSizes.base,
276
+ fontWeight: theme.fontWeights.normal as TextStyle['fontWeight'],
277
+ color: theme.colors.foreground,
278
+ };
279
+ }
280
+ }
281
+
282
+ /* ------------------------------------------------------------------ Card -- */
283
+
284
+ export function cardStyle(theme: Theme): ViewStyle {
285
+ return {
286
+ backgroundColor: theme.colors.card,
287
+ borderRadius: theme.radii.lg,
288
+ borderWidth: 1,
289
+ borderColor: theme.colors.border,
290
+ // Subtle "lit from above" highlight at the top edge for depth.
291
+ borderTopColor: 'rgba(255,255,255,0.06)',
292
+ padding: theme.spacing.lg,
293
+ // Floating shadow (RN shadow* + web elevation). Cheap depth on dark themes.
294
+ shadowColor: '#000',
295
+ shadowOpacity: 0.35,
296
+ shadowRadius: 16,
297
+ shadowOffset: { width: 0, height: 8 },
298
+ elevation: 4,
299
+ };
300
+ }
301
+
302
+ /* ----------------------------------------------------------------- Avatar -- */
303
+
304
+ export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
305
+
306
+ const AVATAR_DIAMETER: Record<AvatarSize, number> = { sm: 32, md: 44, lg: 64, xl: 96 };
307
+
308
+ export function avatarStyle(theme: Theme, size: AvatarSize, color?: string): ViewStyle {
309
+ const d = AVATAR_DIAMETER[size];
310
+ return {
311
+ width: d,
312
+ height: d,
313
+ borderRadius: theme.radii.full,
314
+ backgroundColor: color ?? theme.colors.secondary,
315
+ alignItems: 'center',
316
+ justifyContent: 'center',
317
+ overflow: 'hidden',
318
+ };
319
+ }
320
+
321
+ export function avatarTextStyle(theme: Theme, size: AvatarSize, onColor?: string): TextStyle {
322
+ const fontSize = size === 'lg' ? theme.fontSizes.xl : size === 'md' ? theme.fontSizes.base : theme.fontSizes.sm;
323
+ return {
324
+ fontSize,
325
+ fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'],
326
+ color: onColor ?? theme.colors.secondaryForeground,
327
+ };
328
+ }
329
+
330
+ /* --------------------------------------------------------------- StatTile -- */
331
+
332
+ export type StatDelta = 'up' | 'down' | 'neutral';
333
+
334
+ export function statTileStyle(theme: Theme): ViewStyle {
335
+ return {
336
+ flex: 1,
337
+ minWidth: 140,
338
+ backgroundColor: theme.colors.card,
339
+ borderRadius: theme.radii.lg,
340
+ borderWidth: 1,
341
+ borderColor: theme.colors.border,
342
+ padding: theme.spacing.md,
343
+ gap: theme.spacing.xs,
344
+ // Floating shadow for tile depth on dark themes.
345
+ shadowColor: '#000',
346
+ shadowOpacity: 0.25,
347
+ shadowRadius: 12,
348
+ shadowOffset: { width: 0, height: 6 },
349
+ elevation: 3,
350
+ };
351
+ }
352
+
353
+ export function statValueStyle(theme: Theme): TextStyle {
354
+ return {
355
+ fontFamily: theme.fontFamily,
356
+ fontSize: theme.fontSizes['3xl'],
357
+ fontWeight: theme.fontWeights.extrabold as TextStyle['fontWeight'],
358
+ color: theme.colors.cardForeground,
359
+ letterSpacing: -0.5,
360
+ };
361
+ }
362
+
363
+ export function statLabelStyle(theme: Theme): TextStyle {
364
+ return {
365
+ fontFamily: theme.fontFamily,
366
+ fontSize: theme.fontSizes.xs,
367
+ color: theme.colors.mutedForeground,
368
+ textTransform: 'uppercase',
369
+ letterSpacing: 0.4,
370
+ };
371
+ }
372
+
373
+ export function statDeltaColor(theme: Theme, delta: StatDelta): string {
374
+ if (delta === 'up') return theme.colors.primary;
375
+ if (delta === 'down') return theme.colors.destructive;
376
+ return theme.colors.mutedForeground;
377
+ }
378
+
379
+ /* ---------------------------------------------------------------- ListRow -- */
380
+
381
+ export function listRowStyle(theme: Theme): ViewStyle {
382
+ return {
383
+ flexDirection: 'row',
384
+ alignItems: 'center',
385
+ gap: theme.spacing.md,
386
+ paddingVertical: theme.spacing.sm,
387
+ };
388
+ }
389
+
390
+ export function listRowTitleStyle(theme: Theme): TextStyle {
391
+ return {
392
+ fontSize: theme.fontSizes.base,
393
+ fontWeight: theme.fontWeights.medium as TextStyle['fontWeight'],
394
+ color: theme.colors.foreground,
395
+ };
396
+ }
397
+
398
+ export function listRowSubtitleStyle(theme: Theme): TextStyle {
399
+ return { fontSize: theme.fontSizes.sm, color: theme.colors.mutedForeground };
400
+ }
401
+
402
+ /* --------------------------------------------------------------- Timeline -- */
403
+
404
+ export function timelineDotStyle(theme: Theme, color?: string): ViewStyle {
405
+ return {
406
+ width: 11,
407
+ height: 11,
408
+ borderRadius: theme.radii.full,
409
+ backgroundColor: color ?? theme.colors.primary,
410
+ marginTop: 3,
411
+ };
412
+ }
413
+
414
+ export function timelineConnectorStyle(theme: Theme): ViewStyle {
415
+ return { flex: 1, width: 2, backgroundColor: theme.colors.border, marginTop: 2 };
416
+ }
417
+
418
+ /* ------------------------------------------------------------------- Chip -- */
419
+
420
+ export function chipStyle(theme: Theme, selected: boolean): ViewStyle {
421
+ return {
422
+ paddingHorizontal: theme.spacing.md,
423
+ paddingVertical: 6,
424
+ borderRadius: theme.radii.full,
425
+ borderWidth: 1,
426
+ borderColor: selected ? theme.colors.primary : theme.colors.border,
427
+ backgroundColor: selected ? theme.colors.primary : 'transparent',
428
+ };
429
+ }
430
+
431
+ export function chipTextStyle(theme: Theme, selected: boolean): TextStyle {
432
+ return {
433
+ fontSize: theme.fontSizes.sm,
434
+ fontWeight: (selected ? theme.fontWeights.semibold : theme.fontWeights.medium) as TextStyle['fontWeight'],
435
+ color: selected ? theme.colors.primaryForeground : theme.colors.mutedForeground,
436
+ };
437
+ }
438
+
439
+ /* --------------------------------------------------------------- ShareBar -- */
440
+
441
+ export function shareBarTrackStyle(theme: Theme, height: number): ViewStyle {
442
+ return {
443
+ flexDirection: 'row',
444
+ height,
445
+ borderRadius: theme.radii.full,
446
+ overflow: 'hidden',
447
+ backgroundColor: theme.colors.muted,
448
+ };
449
+ }
450
+
451
+ /* ----------------------------------------------------------------- TabBar -- */
452
+
453
+ export function tabBarStyle(theme: Theme): ViewStyle {
454
+ return {
455
+ flexDirection: 'row',
456
+ borderTopWidth: 1,
457
+ borderTopColor: theme.colors.border,
458
+ backgroundColor: theme.colors.card,
459
+ paddingTop: theme.spacing.sm,
460
+ };
461
+ }
462
+
463
+ export function tabItemStyle(theme: Theme): ViewStyle {
464
+ return {
465
+ flex: 1,
466
+ alignItems: 'center',
467
+ justifyContent: 'center',
468
+ gap: 2,
469
+ paddingVertical: theme.spacing.xs,
470
+ };
471
+ }
472
+
473
+ export function tabLabelStyle(theme: Theme, active: boolean): TextStyle {
474
+ return {
475
+ fontSize: theme.fontSizes.xs,
476
+ fontWeight: (active ? theme.fontWeights.semibold : theme.fontWeights.normal) as TextStyle['fontWeight'],
477
+ color: active ? theme.colors.primary : theme.colors.mutedForeground,
478
+ };
479
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Resolved theme objects for React Native.
3
+ *
4
+ * Turns the channel-string palette into ready-to-use hsl() colors and bundles
5
+ * them with the scheme-independent shape/spacing/type scales. Components and
6
+ * style functions take a Theme so light/dark (or a custom palette) is a single
7
+ * swap at the provider/root.
8
+ */
9
+
10
+ import {
11
+ palette,
12
+ hsl,
13
+ radii,
14
+ spacing,
15
+ fontSizes,
16
+ fontWeights,
17
+ fontFamily,
18
+ durations,
19
+ type ColorScheme,
20
+ type ColorToken,
21
+ } from './tokens';
22
+
23
+ export interface Theme {
24
+ scheme: ColorScheme;
25
+ colors: Record<ColorToken, string>;
26
+ radii: typeof radii;
27
+ spacing: typeof spacing;
28
+ fontSizes: typeof fontSizes;
29
+ fontWeights: typeof fontWeights;
30
+ /** Base font-family stack. Web apps can load a custom face (e.g. Inter)
31
+ * via document.head before mounting; native apps fall through to system. */
32
+ fontFamily: string;
33
+ durations: typeof durations;
34
+ }
35
+
36
+ export function makeTheme(scheme: ColorScheme): Theme {
37
+ const channels = palette[scheme];
38
+ const colors = {} as Record<ColorToken, string>;
39
+ for (const key of Object.keys(channels) as ColorToken[]) {
40
+ colors[key] = hsl(channels[key]);
41
+ }
42
+ return { scheme, colors, radii, spacing, fontSizes, fontWeights, fontFamily, durations };
43
+ }
44
+
45
+ export const lightTheme = makeTheme('light');
46
+ export const darkTheme = makeTheme('dark');
package/src/tokens.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Platform-neutral design tokens for React Native.
3
+ *
4
+ * Colors are stored as HSL channel strings ("H S% L%") that mirror the CSS
5
+ * custom properties in packages/ui/theme/contract.css — the same source of
6
+ * truth the web Tailwind preset consumes via hsl(var(--token)). Keep the two in
7
+ * sync (follow-up: generate one from the other). The hsl() helper turns a
8
+ * channel string into a color React Native's parser understands.
9
+ *
10
+ * Pure data + a pure helper: no react-native import, so this module is safe to
11
+ * import from web tooling too.
12
+ */
13
+
14
+ export type ColorToken =
15
+ | 'background'
16
+ | 'foreground'
17
+ | 'card'
18
+ | 'cardForeground'
19
+ | 'primary'
20
+ | 'primaryForeground'
21
+ | 'secondary'
22
+ | 'secondaryForeground'
23
+ | 'muted'
24
+ | 'mutedForeground'
25
+ | 'accent'
26
+ | 'accentForeground'
27
+ | 'destructive'
28
+ | 'destructiveForeground'
29
+ | 'success'
30
+ | 'successForeground'
31
+ | 'warning'
32
+ | 'warningForeground'
33
+ | 'border'
34
+ | 'input'
35
+ | 'ring';
36
+
37
+ export type ColorScheme = 'light' | 'dark';
38
+
39
+ export const palette: Record<ColorScheme, Record<ColorToken, string>> = {
40
+ light: {
41
+ background: '0 0% 100%',
42
+ foreground: '222.2 84% 4.9%',
43
+ card: '0 0% 100%',
44
+ cardForeground: '222.2 84% 4.9%',
45
+ primary: '221.2 83.2% 53.3%',
46
+ primaryForeground: '210 40% 98%',
47
+ secondary: '210 40% 96.1%',
48
+ secondaryForeground: '222.2 47.4% 11.2%',
49
+ muted: '210 40% 96.1%',
50
+ mutedForeground: '215.4 16.3% 46.9%',
51
+ accent: '210 40% 96.1%',
52
+ accentForeground: '222.2 47.4% 11.2%',
53
+ destructive: '0 84.2% 60.2%',
54
+ destructiveForeground: '210 40% 98%',
55
+ success: '142.1 76.2% 36.3%',
56
+ successForeground: '210 40% 98%',
57
+ warning: '38 92% 50%',
58
+ warningForeground: '222.2 47.4% 11.2%',
59
+ border: '214.3 31.8% 91.4%',
60
+ input: '214.3 31.8% 91.4%',
61
+ ring: '221.2 83.2% 53.3%',
62
+ },
63
+ dark: {
64
+ background: '222.2 84% 4.9%',
65
+ foreground: '210 40% 98%',
66
+ card: '222.2 84% 4.9%',
67
+ cardForeground: '210 40% 98%',
68
+ primary: '217.2 91.2% 59.8%',
69
+ primaryForeground: '222.2 47.4% 11.2%',
70
+ secondary: '217.2 32.6% 17.5%',
71
+ secondaryForeground: '210 40% 98%',
72
+ muted: '217.2 32.6% 17.5%',
73
+ mutedForeground: '215 20.2% 65.1%',
74
+ accent: '217.2 32.6% 17.5%',
75
+ accentForeground: '210 40% 98%',
76
+ destructive: '0 62.8% 30.6%',
77
+ destructiveForeground: '210 40% 98%',
78
+ success: '142.1 70.6% 45.3%',
79
+ successForeground: '144.9 80.4% 10%',
80
+ warning: '37.7 92.1% 50.2%',
81
+ warningForeground: '20.9 91.7% 14.1%',
82
+ border: '217.2 32.6% 17.5%',
83
+ input: '217.2 32.6% 17.5%',
84
+ ring: '224.3 76.3% 48%',
85
+ },
86
+ };
87
+
88
+ /** Convert an HSL channel string ("221.2 83.2% 53.3%") into a React
89
+ * Native-parseable color ("hsl(221.2, 83.2%, 53.3%)"). */
90
+ export function hsl(channels: string): string {
91
+ const [h, s, l] = channels.trim().split(/\s+/);
92
+ return `hsl(${h}, ${s}, ${l})`;
93
+ }
94
+
95
+ /** Corner radii in px, derived from contract.css (--radius: 0.5rem = 8px). */
96
+ export const radii = {
97
+ sm: 4,
98
+ md: 8,
99
+ lg: 12,
100
+ xl: 16,
101
+ full: 9999,
102
+ } as const;
103
+
104
+ /** 4px-based spacing scale for StyleSheet (web relies on Tailwind's scale). */
105
+ export const spacing = {
106
+ xs: 4,
107
+ sm: 8,
108
+ md: 12,
109
+ lg: 16,
110
+ xl: 24,
111
+ '2xl': 32,
112
+ } as const;
113
+
114
+ export const fontSizes = {
115
+ xs: 12,
116
+ sm: 14,
117
+ base: 16,
118
+ lg: 18,
119
+ xl: 20,
120
+ '2xl': 24,
121
+ '3xl': 32,
122
+ '4xl': 48,
123
+ '5xl': 64,
124
+ } as const;
125
+
126
+ export const fontWeights = {
127
+ normal: '400',
128
+ medium: '500',
129
+ semibold: '600',
130
+ bold: '700',
131
+ extrabold: '800',
132
+ black: '900',
133
+ } as const;
134
+
135
+ /** Base font-family stack. Inter (the display face) loaded via web by the
136
+ * app (document.head); falls through to system on native or if Inter fails. */
137
+ export const fontFamily =
138
+ '"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
139
+
140
+ /** Animation durations in ms, from contract.css. */
141
+ export const durations = {
142
+ fast: 100,
143
+ normal: 200,
144
+ slow: 300,
145
+ } as const;