@nexub/react-native 0.0.0-stage → 2.2.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 (134) hide show
  1. package/README.md +113 -2
  2. package/lib/module/components/actions.js +222 -0
  3. package/lib/module/components/actions.js.map +1 -0
  4. package/lib/module/components/containers.js +300 -0
  5. package/lib/module/components/containers.js.map +1 -0
  6. package/lib/module/components/display.js +299 -0
  7. package/lib/module/components/display.js.map +1 -0
  8. package/lib/module/components/feedback.js +484 -0
  9. package/lib/module/components/feedback.js.map +1 -0
  10. package/lib/module/components/inputs.js +792 -0
  11. package/lib/module/components/inputs.js.map +1 -0
  12. package/lib/module/components/mobile.js +818 -0
  13. package/lib/module/components/mobile.js.map +1 -0
  14. package/lib/module/components/overlays.js +421 -0
  15. package/lib/module/components/overlays.js.map +1 -0
  16. package/lib/module/components/shared.js +57 -0
  17. package/lib/module/components/shared.js.map +1 -0
  18. package/lib/module/hooks/useControllableState.js +23 -0
  19. package/lib/module/hooks/useControllableState.js.map +1 -0
  20. package/lib/module/index.js +31 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/package.json +1 -0
  23. package/lib/module/patterns/app.js +709 -0
  24. package/lib/module/patterns/app.js.map +1 -0
  25. package/lib/module/patterns/auth.js +224 -0
  26. package/lib/module/patterns/auth.js.map +1 -0
  27. package/lib/module/patterns/home.js +201 -0
  28. package/lib/module/patterns/home.js.map +1 -0
  29. package/lib/module/patterns/index.js +8 -0
  30. package/lib/module/patterns/index.js.map +1 -0
  31. package/lib/module/patterns/money.js +419 -0
  32. package/lib/module/patterns/money.js.map +1 -0
  33. package/lib/module/primitives/Icon.js +17 -0
  34. package/lib/module/primitives/Icon.js.map +1 -0
  35. package/lib/module/primitives/Layout.js +79 -0
  36. package/lib/module/primitives/Layout.js.map +1 -0
  37. package/lib/module/primitives/Pressable.js +79 -0
  38. package/lib/module/primitives/Pressable.js.map +1 -0
  39. package/lib/module/primitives/ScreenContainer.js +58 -0
  40. package/lib/module/primitives/ScreenContainer.js.map +1 -0
  41. package/lib/module/primitives/Surface.js +57 -0
  42. package/lib/module/primitives/Surface.js.map +1 -0
  43. package/lib/module/primitives/Text.js +70 -0
  44. package/lib/module/primitives/Text.js.map +1 -0
  45. package/lib/module/theme/ThemeProvider.js +127 -0
  46. package/lib/module/theme/ThemeProvider.js.map +1 -0
  47. package/lib/module/theme/fonts.js +56 -0
  48. package/lib/module/theme/fonts.js.map +1 -0
  49. package/lib/module/theme/haptics.js +56 -0
  50. package/lib/module/theme/haptics.js.map +1 -0
  51. package/lib/module/theme/motion.js +89 -0
  52. package/lib/module/theme/motion.js.map +1 -0
  53. package/lib/module/theme/navigation.js +99 -0
  54. package/lib/module/theme/navigation.js.map +1 -0
  55. package/lib/typescript/components/actions.d.ts +53 -0
  56. package/lib/typescript/components/actions.d.ts.map +1 -0
  57. package/lib/typescript/components/containers.d.ts +59 -0
  58. package/lib/typescript/components/containers.d.ts.map +1 -0
  59. package/lib/typescript/components/display.d.ts +68 -0
  60. package/lib/typescript/components/display.d.ts.map +1 -0
  61. package/lib/typescript/components/feedback.d.ts +96 -0
  62. package/lib/typescript/components/feedback.d.ts.map +1 -0
  63. package/lib/typescript/components/inputs.d.ts +141 -0
  64. package/lib/typescript/components/inputs.d.ts.map +1 -0
  65. package/lib/typescript/components/mobile.d.ts +173 -0
  66. package/lib/typescript/components/mobile.d.ts.map +1 -0
  67. package/lib/typescript/components/overlays.d.ts +74 -0
  68. package/lib/typescript/components/overlays.d.ts.map +1 -0
  69. package/lib/typescript/components/shared.d.ts +20 -0
  70. package/lib/typescript/components/shared.d.ts.map +1 -0
  71. package/lib/typescript/hooks/useControllableState.d.ts +6 -0
  72. package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
  73. package/lib/typescript/index.d.ts +39 -0
  74. package/lib/typescript/index.d.ts.map +1 -0
  75. package/lib/typescript/package.json +1 -0
  76. package/lib/typescript/patterns/app.d.ts +139 -0
  77. package/lib/typescript/patterns/app.d.ts.map +1 -0
  78. package/lib/typescript/patterns/auth.d.ts +54 -0
  79. package/lib/typescript/patterns/auth.d.ts.map +1 -0
  80. package/lib/typescript/patterns/home.d.ts +50 -0
  81. package/lib/typescript/patterns/home.d.ts.map +1 -0
  82. package/lib/typescript/patterns/index.d.ts +9 -0
  83. package/lib/typescript/patterns/index.d.ts.map +1 -0
  84. package/lib/typescript/patterns/money.d.ts +106 -0
  85. package/lib/typescript/patterns/money.d.ts.map +1 -0
  86. package/lib/typescript/primitives/Icon.d.ts +12 -0
  87. package/lib/typescript/primitives/Icon.d.ts.map +1 -0
  88. package/lib/typescript/primitives/Layout.d.ts +26 -0
  89. package/lib/typescript/primitives/Layout.d.ts.map +1 -0
  90. package/lib/typescript/primitives/Pressable.d.ts +21 -0
  91. package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
  92. package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
  93. package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
  94. package/lib/typescript/primitives/Surface.d.ts +23 -0
  95. package/lib/typescript/primitives/Surface.d.ts.map +1 -0
  96. package/lib/typescript/primitives/Text.d.ts +21 -0
  97. package/lib/typescript/primitives/Text.d.ts.map +1 -0
  98. package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
  99. package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
  100. package/lib/typescript/theme/fonts.d.ts +25 -0
  101. package/lib/typescript/theme/fonts.d.ts.map +1 -0
  102. package/lib/typescript/theme/haptics.d.ts +3 -0
  103. package/lib/typescript/theme/haptics.d.ts.map +1 -0
  104. package/lib/typescript/theme/motion.d.ts +79 -0
  105. package/lib/typescript/theme/motion.d.ts.map +1 -0
  106. package/lib/typescript/theme/navigation.d.ts +158 -0
  107. package/lib/typescript/theme/navigation.d.ts.map +1 -0
  108. package/package.json +138 -3
  109. package/src/components/actions.tsx +259 -0
  110. package/src/components/containers.tsx +352 -0
  111. package/src/components/display.tsx +345 -0
  112. package/src/components/feedback.tsx +541 -0
  113. package/src/components/inputs.tsx +899 -0
  114. package/src/components/mobile.tsx +883 -0
  115. package/src/components/overlays.tsx +488 -0
  116. package/src/components/shared.ts +54 -0
  117. package/src/hooks/useControllableState.ts +27 -0
  118. package/src/index.ts +177 -0
  119. package/src/patterns/app.tsx +715 -0
  120. package/src/patterns/auth.tsx +266 -0
  121. package/src/patterns/home.tsx +211 -0
  122. package/src/patterns/index.ts +59 -0
  123. package/src/patterns/money.tsx +458 -0
  124. package/src/primitives/Icon.tsx +15 -0
  125. package/src/primitives/Layout.tsx +71 -0
  126. package/src/primitives/Pressable.tsx +97 -0
  127. package/src/primitives/ScreenContainer.tsx +65 -0
  128. package/src/primitives/Surface.tsx +75 -0
  129. package/src/primitives/Text.tsx +79 -0
  130. package/src/theme/ThemeProvider.tsx +210 -0
  131. package/src/theme/fonts.ts +52 -0
  132. package/src/theme/haptics.ts +57 -0
  133. package/src/theme/motion.ts +105 -0
  134. package/src/theme/navigation.ts +68 -0
@@ -0,0 +1,75 @@
1
+ import * as React from 'react';
2
+ import { Platform, StyleSheet, View, type ViewProps } from 'react-native';
3
+ import type { NativeTokens } from '@nexub/tokens/native';
4
+ import { useTheme, type ElevationLevel, type RoleName } from '../theme/ThemeProvider';
5
+
6
+ // Optional peer: native Liquid Glass on iOS 26+. Everything else gets the solid fallback.
7
+ let Glass: typeof import('expo-glass-effect') | undefined;
8
+ try {
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer, must not fail when absent
10
+ Glass = require('expo-glass-effect');
11
+ } catch {
12
+ Glass = undefined;
13
+ }
14
+ const liquidGlass = Platform.OS === 'ios' && !!Glass?.isLiquidGlassAvailable();
15
+
16
+ export type SurfaceTone = Extract<RoleName, `surface${string}`>;
17
+ export type ShapeName = keyof NativeTokens['shape'];
18
+
19
+ export interface SurfaceProps extends ViewProps {
20
+ /** Tonal tier. @default 'surface' */
21
+ tone?: SurfaceTone;
22
+ /** @default 0 */
23
+ elevation?: ElevationLevel;
24
+ /** 'glass' is for the control layer only (bars, floating controls), never content. @default 'solid' */
25
+ material?: 'solid' | 'glass';
26
+ /** Corner radius: a shape token or dp. */
27
+ radius?: ShapeName | number;
28
+ }
29
+
30
+ /**
31
+ * A tonal surface. `material="glass"` is native Liquid Glass on iOS 26+, and
32
+ * `surface-elevated` + hairline elsewhere, or whenever glass is 'off' (user
33
+ * setting or Reduce Transparency).
34
+ */
35
+ export function Surface({
36
+ tone = 'surface',
37
+ elevation = 0,
38
+ material = 'solid',
39
+ radius,
40
+ style,
41
+ ...rest
42
+ }: SurfaceProps) {
43
+ const theme = useTheme();
44
+ const borderRadius = typeof radius === 'string' ? theme.tokens.shape[radius] : radius;
45
+ const glass = material === 'glass' && theme.glass !== 'off';
46
+
47
+ if (glass && liquidGlass && Glass) {
48
+ return (
49
+ <Glass.GlassView
50
+ glassEffectStyle={theme.glass === 'clear' ? 'clear' : 'regular'}
51
+ tintColor={theme.glass === 'tinted' ? theme.roles['surface-elevated'] : undefined}
52
+ colorScheme={theme.scheme}
53
+ style={[{ borderRadius, boxShadow: theme.elevation(elevation) }, style]}
54
+ {...rest}
55
+ />
56
+ );
57
+ }
58
+ return (
59
+ <View
60
+ style={[
61
+ {
62
+ backgroundColor: theme.roles[material === 'glass' ? 'surface-elevated' : tone],
63
+ borderRadius,
64
+ boxShadow: theme.elevation(elevation),
65
+ },
66
+ material === 'glass' && {
67
+ borderWidth: StyleSheet.hairlineWidth,
68
+ borderColor: theme.roles['outline-variant'],
69
+ },
70
+ style,
71
+ ]}
72
+ {...rest}
73
+ />
74
+ );
75
+ }
@@ -0,0 +1,79 @@
1
+ import * as React from 'react';
2
+ import { Text as RNText, type TextProps as RNTextProps } from 'react-native';
3
+ import type { NativeTypeRole } from '@nexub/tokens/native';
4
+ import { fontStyle, type FontFamily } from '../theme/fonts';
5
+ import { useTheme, type RoleName } from '../theme/ThemeProvider';
6
+
7
+ type Ramp = NonNullable<RNTextProps['dynamicTypeRamp']>;
8
+
9
+ // iOS Dynamic Type: each role follows its Apple text style's scaling curve.
10
+ const RAMP: Record<NativeTypeRole, Ramp> = {
11
+ 'display-lg': 'largeTitle',
12
+ 'display-md': 'largeTitle',
13
+ 'display-sm': 'largeTitle',
14
+ 'headline-lg': 'title1',
15
+ 'headline-md': 'title2',
16
+ 'headline-sm': 'title3',
17
+ 'title-lg': 'title3',
18
+ 'title-md': 'headline',
19
+ 'title-sm': 'subheadline',
20
+ 'body-lg': 'body',
21
+ 'body-md': 'body',
22
+ 'body-sm': 'footnote',
23
+ 'label-lg': 'subheadline',
24
+ 'label-md': 'footnote',
25
+ 'label-sm': 'caption1',
26
+ };
27
+
28
+ // Big type already reads at large sizes; cap it so it doesn't push content off
29
+ // screen. Body, title and label roles scale without a cap (WCAG 1.4.4).
30
+ const cap = (role: NativeTypeRole) =>
31
+ role.startsWith('display') ? 1.3 : role.startsWith('headline') ? 1.6 : undefined;
32
+
33
+ export interface TextProps extends RNTextProps {
34
+ /** Type role. @default 'body-md' */
35
+ variant?: NativeTypeRole;
36
+ /** Colour role. @default 'on-surface' */
37
+ color?: RoleName;
38
+ /** Tabular figures, for numbers that line up. */
39
+ tabular?: boolean;
40
+ /** Overrides the role's family (e.g. 'mono' for codes). */
41
+ family?: FontFamily;
42
+ }
43
+
44
+ export function Text({
45
+ variant = 'body-md',
46
+ color = 'on-surface',
47
+ tabular,
48
+ family,
49
+ style,
50
+ ...rest
51
+ }: TextProps) {
52
+ const { roles, tokens, fonts } = useTheme();
53
+ const t = tokens.type[variant];
54
+ return (
55
+ <RNText
56
+ dynamicTypeRamp={RAMP[variant]}
57
+ maxFontSizeMultiplier={cap(variant)}
58
+ style={[
59
+ {
60
+ ...fontStyle(family ?? t.family, t.weight, fonts),
61
+ fontSize: t.size,
62
+ lineHeight: t.lineHeight,
63
+ letterSpacing: t.letterSpacing,
64
+ color: roles[color],
65
+ },
66
+ tabular && { fontVariant: ['tabular-nums'] },
67
+ style,
68
+ ]}
69
+ {...rest}
70
+ />
71
+ );
72
+ }
73
+
74
+ export interface HeadingProps extends TextProps {}
75
+
76
+ /** Text announced as a heading. @default variant 'headline-sm' */
77
+ export function Heading({ variant = 'headline-sm', ...rest }: HeadingProps) {
78
+ return <Text accessibilityRole="header" variant={variant} {...rest} />;
79
+ }
@@ -0,0 +1,210 @@
1
+ import * as React from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ AppState,
5
+ Platform,
6
+ StyleSheet,
7
+ View,
8
+ useColorScheme,
9
+ } from 'react-native';
10
+ import { native, type NativeRoles } from '@nexub/tokens/native';
11
+ import { createSeedRoles } from '@nexub/tokens/seed';
12
+ import {
13
+ defaultMessages,
14
+ directionOf,
15
+ type ContrastLevel,
16
+ type Direction,
17
+ type GlassMode,
18
+ type NexubMessages,
19
+ type Product,
20
+ } from '@nexub/core';
21
+ import { playHaptic, type HapticToken } from './haptics';
22
+ import type { FontSource } from './fonts';
23
+
24
+ export type Scheme = 'light' | 'dark';
25
+ export type Roles = { -readonly [K in keyof NativeRoles]: string };
26
+ export type RoleName = keyof Roles;
27
+ export type ElevationLevel = 0 | 1 | 2 | 3;
28
+
29
+ export interface NexubTheme {
30
+ scheme: Scheme;
31
+ contrast: ContrastLevel;
32
+ product: Product | undefined;
33
+ /** Colour roles, already resolved for scheme, contrast, product and seed. */
34
+ roles: Roles;
35
+ /** Every native token (spacing, shape, size, type, state, motion). */
36
+ tokens: typeof native;
37
+ /** `boxShadow` for an elevation level, undefined for 0. */
38
+ elevation: (level: ElevationLevel) => string | undefined;
39
+ /** Effective glass: 'off' when the user has Reduce Transparency on. */
40
+ glass: GlassMode;
41
+ reducedMotion: boolean;
42
+ haptics: boolean;
43
+ fonts: FontSource;
44
+ locale: string;
45
+ dir: Direction;
46
+ messages: NexubMessages;
47
+ }
48
+
49
+ export interface NexubNativeProviderProps {
50
+ /** @default 'system' */
51
+ scheme?: Scheme | 'system';
52
+ /** 'system' = iOS Increase Contrast / Android high-contrast text → 'high'. @default 'system' */
53
+ contrast?: ContrastLevel | 'system';
54
+ /** Product brand: remaps the tertiary role family. */
55
+ product?: Product;
56
+ /** White-label seed colour (#rgb / #rrggbb); regenerates the `seedTarget` family. */
57
+ seed?: string;
58
+ /** @default 'primary' */
59
+ seedTarget?: 'primary' | 'tertiary';
60
+ /** Glass intensity (the app persists the user's choice). @default 'balanced' */
61
+ glass?: GlassMode;
62
+ /** Haptics on/off (user setting). @default true */
63
+ haptics?: boolean;
64
+ /** 'nexub' needs `nexubFonts` loaded; 'system' uses the platform font. @default 'nexub' */
65
+ fonts?: FontSource;
66
+ /** BCP 47 locale for Intl formatting. @default 'en' */
67
+ locale?: string;
68
+ /** @default derived from `locale` */
69
+ dir?: Direction;
70
+ /** Overrides for the default English strings. */
71
+ messages?: Partial<NexubMessages>;
72
+ children?: React.ReactNode;
73
+ }
74
+
75
+ const ThemeContext = React.createContext<NexubTheme | null>(null);
76
+
77
+ // AccessibilityInfo reads that fire no change event (contrast): re-read on foreground.
78
+ function useForegroundSetting(
79
+ read: (() => Promise<boolean>) | undefined,
80
+ enabled: boolean,
81
+ ): boolean {
82
+ const [on, setOn] = React.useState(false);
83
+ React.useEffect(() => {
84
+ if (!enabled || !read) return;
85
+ const get = () => read().then(setOn, () => setOn(false));
86
+ get();
87
+ const sub = AppState.addEventListener('change', (s) => s === 'active' && get());
88
+ return () => sub.remove();
89
+ }, [enabled, read]);
90
+ return on;
91
+ }
92
+
93
+ function useSetting(
94
+ read: () => Promise<boolean>,
95
+ event: 'reduceMotionChanged' | 'reduceTransparencyChanged',
96
+ ): boolean {
97
+ const [on, setOn] = React.useState(false);
98
+ React.useEffect(() => {
99
+ read().then(setOn, () => setOn(false));
100
+ const sub = AccessibilityInfo.addEventListener(event, setOn);
101
+ return () => sub.remove();
102
+ }, [read, event]);
103
+ return on;
104
+ }
105
+
106
+ type A11y = typeof AccessibilityInfo & {
107
+ isDarkerSystemColorsEnabled?: () => Promise<boolean>;
108
+ isHighTextContrastEnabled?: () => Promise<boolean>;
109
+ };
110
+ const a11y = AccessibilityInfo as A11y;
111
+ const readHighContrast =
112
+ Platform.OS === 'ios'
113
+ ? a11y.isDarkerSystemColorsEnabled?.bind(a11y)
114
+ : a11y.isHighTextContrastEnabled?.bind(a11y);
115
+ const readReduceMotion = () => AccessibilityInfo.isReduceMotionEnabled();
116
+ const readReduceTransparency = () =>
117
+ Platform.OS === 'ios' ? AccessibilityInfo.isReduceTransparencyEnabled() : Promise.resolve(false);
118
+
119
+ /**
120
+ * Theme, accessibility preferences, locale and strings for every
121
+ * @nexub/react-native component. Wrap the app once, inside the safe-area provider.
122
+ */
123
+ export function NexubNativeProvider({
124
+ scheme: schemeProp = 'system',
125
+ contrast: contrastProp = 'system',
126
+ product,
127
+ seed,
128
+ seedTarget = 'primary',
129
+ glass = 'balanced',
130
+ haptics = true,
131
+ fonts = 'nexub',
132
+ locale = 'en',
133
+ dir,
134
+ messages,
135
+ children,
136
+ }: NexubNativeProviderProps) {
137
+ const system = useColorScheme();
138
+ const scheme: Scheme =
139
+ schemeProp === 'system' ? (system === 'dark' ? 'dark' : 'light') : schemeProp;
140
+ const systemHigh = useForegroundSetting(readHighContrast, contrastProp === 'system');
141
+ const contrast: ContrastLevel =
142
+ contrastProp === 'system' ? (systemHigh ? 'high' : 'standard') : contrastProp;
143
+ const reducedMotion = useSetting(readReduceMotion, 'reduceMotionChanged');
144
+ const reduceTransparency = useSetting(readReduceTransparency, 'reduceTransparencyChanged');
145
+ const resolvedDir = dir ?? directionOf(locale);
146
+
147
+ const theme = React.useMemo<NexubTheme>(() => {
148
+ const roles: Roles = {
149
+ ...native.color.roles[contrast][scheme],
150
+ ...(product ? native.color.products[product][contrast][scheme] : {}),
151
+ ...(seed ? createSeedRoles(seed, { contrast, target: seedTarget })[scheme] : {}),
152
+ };
153
+ return {
154
+ scheme,
155
+ contrast,
156
+ product,
157
+ roles,
158
+ tokens: native,
159
+ elevation: (level) => (level ? native.elevation[level][scheme] : undefined),
160
+ glass: reduceTransparency ? 'off' : glass,
161
+ reducedMotion,
162
+ haptics,
163
+ fonts,
164
+ locale,
165
+ dir: resolvedDir,
166
+ messages: { ...defaultMessages, ...messages },
167
+ };
168
+ }, [
169
+ scheme,
170
+ contrast,
171
+ product,
172
+ seed,
173
+ seedTarget,
174
+ glass,
175
+ reduceTransparency,
176
+ reducedMotion,
177
+ haptics,
178
+ fonts,
179
+ locale,
180
+ resolvedDir,
181
+ messages,
182
+ ]);
183
+
184
+ return (
185
+ <ThemeContext.Provider value={theme}>
186
+ <View style={[styles.root, { direction: resolvedDir }]}>{children}</View>
187
+ </ThemeContext.Provider>
188
+ );
189
+ }
190
+
191
+ const styles = StyleSheet.create({ root: { flex: 1 } });
192
+
193
+ /** The active theme. Throws outside NexubNativeProvider. */
194
+ export function useTheme(): NexubTheme {
195
+ const theme = React.useContext(ThemeContext);
196
+ if (!theme) throw new Error('@nexub/react-native: wrap your app in <NexubNativeProvider>.');
197
+ return theme;
198
+ }
199
+
200
+ /** Shorthand for `useTheme().roles`. */
201
+ export const useRoles = (): Roles => useTheme().roles;
202
+
203
+ /** The active strings: defaults merged with the provider's overrides. */
204
+ export const useNexubMessages = (): NexubMessages => useTheme().messages;
205
+
206
+ /** Plays a semantic haptic, unless the user turned haptics off. */
207
+ export function useHaptics(): (token: HapticToken) => void {
208
+ const { haptics } = useTheme();
209
+ return React.useCallback((token) => haptics && playHaptic(token), [haptics]);
210
+ }
@@ -0,0 +1,52 @@
1
+ // Nexub faces as static TTFs (OFL) from @expo-google-fonts, one file per weight.
2
+ // Load them once at startup: const [ready] = useFonts(nexubFonts) (expo-font).
3
+ // Per-weight subpaths don't import expo-font, so bare RN apps can load the same
4
+ // files with their own loader.
5
+ import { Inter_400Regular } from '@expo-google-fonts/inter/400Regular';
6
+ import { Inter_500Medium } from '@expo-google-fonts/inter/500Medium';
7
+ import { Inter_600SemiBold } from '@expo-google-fonts/inter/600SemiBold';
8
+ import { Inter_700Bold } from '@expo-google-fonts/inter/700Bold';
9
+ import { PlusJakartaSans_600SemiBold } from '@expo-google-fonts/plus-jakarta-sans/600SemiBold';
10
+ import { PlusJakartaSans_700Bold } from '@expo-google-fonts/plus-jakarta-sans/700Bold';
11
+ import { PlusJakartaSans_800ExtraBold } from '@expo-google-fonts/plus-jakarta-sans/800ExtraBold';
12
+ import { GeistMono_400Regular } from '@expo-google-fonts/geist-mono/400Regular';
13
+
14
+ export const nexubFonts = {
15
+ Inter_400Regular,
16
+ Inter_500Medium,
17
+ Inter_600SemiBold,
18
+ Inter_700Bold,
19
+ PlusJakartaSans_600SemiBold,
20
+ PlusJakartaSans_700Bold,
21
+ PlusJakartaSans_800ExtraBold,
22
+ GeistMono_400Regular,
23
+ };
24
+
25
+ export type FontFamily = 'sans' | 'display' | 'mono';
26
+ export type FontSource = 'nexub' | 'system';
27
+
28
+ const SANS: Record<number, string> = {
29
+ 400: 'Inter_400Regular',
30
+ 500: 'Inter_500Medium',
31
+ 600: 'Inter_600SemiBold',
32
+ 700: 'Inter_700Bold',
33
+ 800: 'Inter_700Bold',
34
+ };
35
+ const DISPLAY: Record<number, string> = {
36
+ 400: 'PlusJakartaSans_600SemiBold',
37
+ 500: 'PlusJakartaSans_600SemiBold',
38
+ 600: 'PlusJakartaSans_600SemiBold',
39
+ 700: 'PlusJakartaSans_700Bold',
40
+ 800: 'PlusJakartaSans_800ExtraBold',
41
+ };
42
+
43
+ /**
44
+ * Style for a family + weight. With static files the weight lives in the file
45
+ * name, so `fontWeight` is only set for the system font (Android picks the
46
+ * wrong file when both are set).
47
+ */
48
+ export function fontStyle(family: FontFamily, weight: number, source: FontSource) {
49
+ if (source === 'system') return { fontWeight: String(weight) as '400' };
50
+ if (family === 'mono') return { fontFamily: 'GeistMono_400Regular' };
51
+ return { fontFamily: (family === 'display' ? DISPLAY : SANS)[weight] ?? SANS[400] };
52
+ }
@@ -0,0 +1,57 @@
1
+ import { Platform } from 'react-native';
2
+
3
+ // Semantic haptics (MOBILE_PLAN.md §4.4). Apple's rule: never on plain taps,
4
+ // never the only feedback. Android uses the platform constants, not the
5
+ // vibrator-based impact API.
6
+ export type HapticToken =
7
+ 'selection' | 'toggleOn' | 'toggleOff' | 'confirm' | 'reject' | 'warning' | 'snap';
8
+
9
+ // Optional peer: without expo-haptics every haptic is a no-op.
10
+ let H: typeof import('expo-haptics') | undefined;
11
+ try {
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer, must not fail when absent
13
+ H = require('expo-haptics');
14
+ } catch {
15
+ H = undefined;
16
+ }
17
+
18
+ const ANDROID = {
19
+ selection: 'Segment_Tick',
20
+ toggleOn: 'Toggle_On',
21
+ toggleOff: 'Toggle_Off',
22
+ confirm: 'Confirm',
23
+ reject: 'Reject',
24
+ warning: 'Reject',
25
+ snap: 'Gesture_End',
26
+ } as const;
27
+
28
+ export function playHaptic(token: HapticToken): void {
29
+ if (!H) return;
30
+ const done = () => {};
31
+ if (Platform.OS === 'android') {
32
+ H.performAndroidHapticsAsync(H.AndroidHaptics[ANDROID[token]]).catch(done);
33
+ return;
34
+ }
35
+ if (Platform.OS !== 'ios') return;
36
+ switch (token) {
37
+ case 'selection':
38
+ H.selectionAsync().catch(done);
39
+ break;
40
+ case 'toggleOn':
41
+ case 'toggleOff':
42
+ H.impactAsync(H.ImpactFeedbackStyle.Light).catch(done);
43
+ break;
44
+ case 'snap':
45
+ H.impactAsync(H.ImpactFeedbackStyle.Medium).catch(done);
46
+ break;
47
+ case 'confirm':
48
+ H.notificationAsync(H.NotificationFeedbackType.Success).catch(done);
49
+ break;
50
+ case 'reject':
51
+ H.notificationAsync(H.NotificationFeedbackType.Error).catch(done);
52
+ break;
53
+ case 'warning':
54
+ H.notificationAsync(H.NotificationFeedbackType.Warning).catch(done);
55
+ break;
56
+ }
57
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ Easing,
3
+ FadeIn,
4
+ FadeInDown,
5
+ FadeInUp,
6
+ FadeOut,
7
+ FadeOutDown,
8
+ FadeOutUp,
9
+ LinearTransition,
10
+ ReduceMotion,
11
+ SlideOutLeft,
12
+ SlideOutRight,
13
+ ZoomIn,
14
+ ZoomOut,
15
+ } from 'react-native-reanimated';
16
+ import { native } from '@nexub/tokens/native';
17
+
18
+ // Layout-animation presets built from the same spring tokens as the web
19
+ // (`motion.spring.*` → CSS linear() there, Reanimated springs here).
20
+ // Enter rides `spatial` (position and size, a hair of settle); exit is quicker
21
+ // on `effects` and never overshoots. Every preset honours the OS Reduce Motion
22
+ // setting (ReduceMotion.System): the change lands without travel.
23
+
24
+ export type SpringFamily = keyof typeof native.motion.spring;
25
+ export type SpringSpeed = keyof (typeof native.motion.spring)['spatial'];
26
+
27
+ /** `withSpring` config for a spring token, honouring Reduce Motion. */
28
+ export function nxSpring(family: SpringFamily = 'spatial', speed: SpringSpeed = 'default') {
29
+ return { ...native.motion.spring[family][speed], reduceMotion: ReduceMotion.System };
30
+ }
31
+
32
+ export type EnterKind = 'fade' | 'up' | 'down' | 'scale';
33
+ export type ExitKind = 'fade' | 'up' | 'down' | 'scale' | 'start' | 'end';
34
+
35
+ const ENTER = { fade: FadeIn, up: FadeInUp, down: FadeInDown, scale: ZoomIn } as const;
36
+ const EXIT = { fade: FadeOut, up: FadeOutUp, down: FadeOutDown, scale: ZoomOut } as const;
37
+
38
+ /**
39
+ * Settle time of a spring token (ms): when it is within 0.1% of rest. The
40
+ * same rule @nexub/tokens uses for the CSS spring durations.
41
+ */
42
+ export function springSettleMs({ stiffness, damping, mass }: { stiffness: number; damping: number; mass: number }) {
43
+ const dt = 1 / 1000;
44
+ let x = 0;
45
+ let v = 0;
46
+ let still = 0;
47
+ for (let t = 0; t < 3; t += dt) {
48
+ const a = (stiffness * (1 - x) - damping * v) / mass;
49
+ v += a * dt;
50
+ x += v * dt;
51
+ still = Math.abs(1 - x) < 0.001 && Math.abs(v) < 0.01 ? still + 1 : 0;
52
+ if (still > 16) return Math.round((t - 0.016) * 1000);
53
+ }
54
+ return 3000;
55
+ }
56
+
57
+ // Enter and exit run as timed animations lasting the spring token's settle
58
+ // time, on the system decelerate / accelerate curves: Reanimated's
59
+ // physics-spring entering animations (`springify()` with stiffness/damping)
60
+ // left entering views invisible on iOS. Layout transitions move views that are
61
+ // already visible, so they keep the physics spring.
62
+ const decelerate = Easing.bezier(0, 0, 0.2, 1);
63
+ const accelerate = Easing.bezier(0.4, 0, 1, 1);
64
+
65
+ /**
66
+ * `entering` animation, timed to the spatial spring.
67
+ * `up` rises into place (rows, toasts from below); `down` drops in (banners).
68
+ *
69
+ * ```tsx
70
+ * <Animated.View entering={nxEnter('up')} exiting={nxExit('down')} layout={nxLayout()} />
71
+ * ```
72
+ */
73
+ export function nxEnter(kind: EnterKind = 'fade', speed: SpringSpeed = 'default') {
74
+ return ENTER[kind]
75
+ .duration(springSettleMs(native.motion.spring.spatial[speed]))
76
+ .easing(decelerate)
77
+ .reduceMotion(ReduceMotion.System);
78
+ }
79
+
80
+ /**
81
+ * `exiting` animation, timed to the effects spring: quicker than enter.
82
+ * `start` / `end` slide out toward that edge (swipe-to-delete), following RTL.
83
+ */
84
+ export function nxExit(kind: ExitKind = 'fade', speed: SpringSpeed = 'fast', rtl = false) {
85
+ const builder =
86
+ kind === 'start' || kind === 'end'
87
+ ? (kind === 'start') !== rtl
88
+ ? SlideOutLeft
89
+ : SlideOutRight
90
+ : EXIT[kind];
91
+ return builder
92
+ .duration(springSettleMs(native.motion.spring.effects[speed]))
93
+ .easing(accelerate)
94
+ .reduceMotion(ReduceMotion.System);
95
+ }
96
+
97
+ /** `layout` transition: siblings glide to their new place on the spatial spring. */
98
+ export function nxLayout(speed: SpringSpeed = 'default') {
99
+ const s = native.motion.spring.spatial[speed];
100
+ return LinearTransition.springify()
101
+ .stiffness(s.stiffness)
102
+ .damping(s.damping)
103
+ .mass(s.mass)
104
+ .reduceMotion(ReduceMotion.System);
105
+ }
@@ -0,0 +1,68 @@
1
+ // Native chrome, Nexub colours: options for the system navigation (native
2
+ // stack header, tabs, React Navigation theme). We theme it; we never redraw it.
3
+ import { fontStyle } from './fonts';
4
+ import { useTheme, type NexubTheme } from './ThemeProvider';
5
+
6
+ /** Native stack `screenOptions`: large titles on a surface-coloured native header, Nexub type and tint. */
7
+ export function nexubStackOptions(theme: NexubTheme) {
8
+ const { roles, fonts } = theme;
9
+ return {
10
+ headerLargeTitle: true,
11
+ // Opaque: a transparent header hid the expanded large title (iOS 26); the
12
+ // system still draws its scroll-edge effect on an opaque native header.
13
+ headerTransparent: false,
14
+ headerShadowVisible: false,
15
+ headerLargeTitleShadowVisible: false,
16
+ headerTintColor: roles.primary,
17
+ headerStyle: { backgroundColor: roles.surface },
18
+ headerTitleStyle: { color: roles['on-surface'], ...fontStyle('sans', 600, fonts) },
19
+ headerLargeTitleStyle: { color: roles['on-surface'], ...fontStyle('display', 700, fonts) },
20
+ contentStyle: { backgroundColor: roles.surface },
21
+ };
22
+ }
23
+
24
+ /** React Navigation `theme` (also Expo Router's ThemeProvider). */
25
+ export function nexubNavigationTheme(theme: NexubTheme) {
26
+ const { roles, fonts, scheme } = theme;
27
+ const font = (weight: 400 | 500 | 600 | 700) => {
28
+ const f = fontStyle('sans', weight, fonts);
29
+ return {
30
+ fontFamily: 'fontFamily' in f ? (f.fontFamily as string) : 'System',
31
+ fontWeight: String(weight) as '400',
32
+ };
33
+ };
34
+ return {
35
+ dark: scheme === 'dark',
36
+ colors: {
37
+ primary: roles.primary,
38
+ background: roles.surface,
39
+ card: roles['surface-container'],
40
+ text: roles['on-surface'],
41
+ border: roles['outline-variant'],
42
+ notification: roles.error,
43
+ },
44
+ fonts: { regular: font(400), medium: font(500), bold: font(600), heavy: font(700) },
45
+ };
46
+ }
47
+
48
+ /** Colours for native tab bars (Expo Router NativeTabs / react-native-bottom-tabs). */
49
+ export function nexubTabsOptions(theme: NexubTheme) {
50
+ const { roles, fonts } = theme;
51
+ return {
52
+ tintColor: roles.primary,
53
+ inactiveTintColor: roles['on-surface-variant'],
54
+ backgroundColor: roles['surface-container'],
55
+ badgeBackgroundColor: roles.error,
56
+ labelStyle: { ...fontStyle('sans', 500, fonts), fontSize: 11 },
57
+ };
58
+ }
59
+
60
+ /** All three for the current theme: `const nav = useNexubNavigation()`. */
61
+ export function useNexubNavigation() {
62
+ const theme = useTheme();
63
+ return {
64
+ stack: nexubStackOptions(theme),
65
+ theme: nexubNavigationTheme(theme),
66
+ tabs: nexubTabsOptions(theme),
67
+ };
68
+ }