@vvedantb/native-ui 0.6.1 → 0.7.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.
package/README.md CHANGED
@@ -23,6 +23,7 @@ Optional, only if you import those components:
23
23
 
24
24
  - `@gorhom/bottom-sheet` — `BottomSheetContainer`
25
25
  - `@react-native-community/datetimepicker` and `dayjs` — `DateTimeField`
26
+ - `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and `expo-router` — `@vvedantb/native-ui/auth` (0.7.0). `expo-router` also for `@vvedantb/native-ui/expo-web-root`.
26
27
  - `react-native-svg` — the app marks (`APP_ICONS`, `Icon*`, `AppMark`, `AppWordmark`), `GoogleMark` and `PricingModal`. Apps with the icon peers above already have it: `lucide-react-native` and `@tabler/icons-react-native` both require it.
27
28
 
28
29
  Optional and loaded only when installed, so the package imports and runs without them:
@@ -75,10 +76,13 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
75
76
  | `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
76
77
  | `@vvedantb/native-ui/ai` | The AI chat parts (from 0.4.0): `Conversation`, `Message`, `Reasoning`, `AgentTrace`, `ChainOfThought`, `ActivityStep`, `PromptInput`, `Suggestions`, `Sources`, `FileChip`, `Attachments`, `CodeBlock`, `ResponseText`, `Shimmer`, `Collapsible`, `useStreamingDisclosure` and their parts. See [AI chat](#ai-chat-040). Not on the root entry. | none |
77
78
  | `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
79
+ | `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage` and their props types. See [Auth](#auth-070). Not on the root entry. | `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser`, `expo-router` (import) |
80
+ | `@vvedantb/native-ui/expo-web-root` | From 0.7.0: `ExpoWebRoot`, `ExpoWebRootProps`, `ExpoWebRootViewport`, `EXPO_WEB_VIEWPORT`, `expoWebBackgroundCss`. Not on the root entry. | `expo-router` (import of `expo-router/html`) |
81
+ | `@vvedantb/native-ui/tailwind-preset` | From 0.7.0, CommonJS for `tailwind.config.js`: `nativeUiPreset`, `nativeUiContent`, `cssVar`, `hslVar`, `rgbAlpha`, `cssVariables`, `schemeColors` and a default preset. See [Expo / NativeWind](#expo--nativewind). | `nativewind` (`require`, when installed) |
78
82
 
79
83
  "Loader" means the `require` inside `try` described above, so the entry bundles and runs without that peer. `@vvedantb/native-ui/gallery` imports the root entry; `@vvedantb/native-ui/ai-gallery` imports `/ai` only.
80
84
 
81
- The AI chat parts are only on `/ai`. Metro does not tree-shake by default, so on the root entry they would bundle into every app, with or without a chat.
85
+ The AI chat parts are only on `/ai`, and the auth screens and Expo web root only on `/auth` and `/expo-web-root`. Metro does not tree-shake by default, so on the root entry they would bundle into every app, with or without a chat or sign-in.
82
86
 
83
87
  ```tsx
84
88
  import { FloatingTabBar } from "@vvedantb/native-ui/tab-bar";
@@ -93,18 +97,33 @@ The entries re-export the same source files, so they can be mixed: `NativeUiProv
93
97
 
94
98
  ## Expo / NativeWind
95
99
 
96
- Source-exported. Point Tailwind at the package so class names are generated, and set `darkMode: "class"` (see [Theme](#theme)):
100
+ Source-exported. From 0.7.0 the preset sets up Tailwind for the package: it nests `nativewind/preset`, sets `darkMode: "class"` (see [Theme](#theme)), maps the Instrument Sans font classes and adds the colour keys the components and apps share. `nativeUiContent()` points Tailwind at the package source, at its real path, so class names are generated:
97
101
 
98
102
  ```js
99
103
  // tailwind.config.js
100
- darkMode: "class",
101
- content: [
102
- "./app/**/*.{js,ts,tsx}",
103
- "./components/**/*.{js,ts,tsx}",
104
- "./node_modules/@vvedantb/native-ui/src/**/*.{js,ts,tsx}",
105
- ],
104
+ const { nativeUiPreset, nativeUiContent } = require("@vvedantb/native-ui/tailwind-preset");
105
+
106
+ module.exports = {
107
+ content: ["./app/**/*.{js,ts,tsx}", "./components/**/*.{js,ts,tsx}", nativeUiContent()],
108
+ presets: [nativeUiPreset()],
109
+ // App colours, display fonts, keyframes and plugins (such as tailwindcss-animate) stay here.
110
+ theme: { extend: {} },
111
+ };
106
112
  ```
107
113
 
114
+ `nativeUiPreset(options)`:
115
+
116
+ | Option | Default | Effect |
117
+ | --- | --- | --- |
118
+ | `darkMode` | `"class"` | `"media"` follows the system only. `ThemeToggle` and `setColorScheme` need `"class"`. |
119
+ | `color` | `cssVar`, or `rgbAlpha` with `injectCssVariables` | Maps a variable name to a colour value. `cssVar` gives `var(--name)`, `hslVar` gives `hsl(var(--name))` and `rgbAlpha` gives `rgb(var(--name) / <alpha-value>)`. For another prefix, pass your own, such as ``(name) => `rgb(var(--color-${name}) / <alpha-value>)` ``. |
120
+ | `injectCssVariables` | `false` | Writes every colour variable from `tokens.ts` and `palette.ts` as `r g b` channels, and `--radius` (16px), for light (`:root`) and dark (`.dark`, or the media query with `darkMode: "media"`). The app's `global.css` then needs no token block. |
121
+ | `radiusVariable` | `injectCssVariables` | `rounded-lg`, `-md` and `-sm` from `var(--radius)`. |
122
+
123
+ The colour keys are `background`, `foreground`, `border`, `input`, `ring`, `separator`, `surface` (`DEFAULT`, `foreground`, `secondary`, `tertiary`, `card`), `field` (`DEFAULT`, `placeholder`) and, each with `DEFAULT` and `foreground`, `primary`, `secondary`, `destructive`, `muted`, `accent`, `popover`, `card`, `danger`, `success`, `warning`, `default` and `overlay`. Each reads the variable of the same name (`surface.secondary` reads `--surface-secondary`; `muted.foreground` reads `--muted-foreground`). `borderWidth.hairline` is NativeWind's `hairlineWidth()`.
124
+
125
+ Before 0.7.0, list the package in `content` by hand (`"./node_modules/@vvedantb/native-ui/src/**/*.{js,ts,tsx}"`) and set `darkMode: "class"`.
126
+
108
127
  Metro already resolves `node_modules`. In a pnpm monorepo, if a peer resolves to the wrong copy, add:
109
128
 
110
129
  ```js
@@ -116,7 +135,7 @@ config.resolver.nodeModulesPaths = [
116
135
  ];
117
136
  ```
118
137
 
119
- The app must define NativeWind tokens (`bg-surface`, `bg-secondary`, pink utilities) and Instrument Sans font classes (`font-instrument-sans-*`). Accent pink stays in the app stylesheet. `Button`, `Input`, `Textarea`, the `Select` trigger, `Switch`, `Checkbox`, `Badge`, `Tabs` and `PillTabs` take their colours from `tokens` and `palette`, so they do not need `primary`, `input` or other theme keys. From 0.3.3 so does component text (titles, descriptions, labels, rows, toasts): it no longer uses `text-foreground` or `text-muted-foreground`, which stay light-mode colours in dark mode when an app defines `foreground` with a nested `dark` key. Override those colours with `style`, not `className`. App text needs the same care: `tokens.color.foreground[scheme]` reads in both schemes. From 0.5.0 the `ListRow`, `DateTimeField` and `HeaderIconButton` fills are styles too, so `ThemeToggle` and the `StackPageHeader` back button follow the scheme.
138
+ Without the preset, the app must define NativeWind tokens (`bg-surface`, `bg-secondary`, pink utilities) and Instrument Sans font classes (`font-instrument-sans-*`). Accent pink stays in the app stylesheet. `Button`, `Input`, `Textarea`, the `Select` trigger, `Switch`, `Checkbox`, `Badge`, `Tabs` and `PillTabs` take their colours from `tokens` and `palette`, so they do not need `primary`, `input` or other theme keys. From 0.3.3 so does component text (titles, descriptions, labels, rows, toasts): it no longer uses `text-foreground` or `text-muted-foreground`, which stay light-mode colours in dark mode when an app defines `foreground` with a nested `dark` key. Override those colours with `style`, not `className`. App text needs the same care: `tokens.color.foreground[scheme]` reads in both schemes. From 0.5.0 the `ListRow`, `DateTimeField` and `HeaderIconButton` fills are styles too, so `ThemeToggle` and the `StackPageHeader` back button follow the scheme.
120
139
 
121
140
  From 0.3.5 text that uses `font-instrument-sans-medium` or `-semibold` also sets `fontWeight` as a style (`typeStyle` in `src/controlStyles.ts`). Where an app maps every `font-instrument-sans-*` class to one family with no weight, as Expo web does, the class alone draws Regular. A weight without the face class does not pick the Instrument Sans Medium or Semibold file, so set both. From 0.3.13 every title in the package pairs the two, the internal primitives included, and `tests/fontWeight.test.ts` fails on a face class without its weight, a weight without its face class, or a bare Tailwind `font-medium`, `font-semibold` or `font-bold`.
122
141
 
@@ -136,6 +155,62 @@ fontFamily: {
136
155
 
137
156
  A class mapped to a family the browser does not have, such as `["System"]`, with no generic fallback draws in the browser default, often a serif.
138
157
 
158
+ ### Expo web root (0.7.0)
159
+
160
+ `app/+html.tsx` can be one component:
161
+
162
+ ```tsx
163
+ import { ExpoWebRoot } from "@vvedantb/native-ui/expo-web-root";
164
+
165
+ export default function Root({ children }: { children: React.ReactNode }) {
166
+ return <ExpoWebRoot>{children}</ExpoWebRoot>;
167
+ }
168
+ ```
169
+
170
+ It renders `<html lang="en">`, the charset, `X-UA-Compatible` and viewport meta tags, `ScrollViewStyleReset` from `expo-router/html`, a body background style and `<body>`. Props: `lang`; `viewport` (`"native"`, the default, stops pinch-zoom; `"accessible"` allows it; both set `viewport-fit=cover`); `scrollReset` (default `true`); `lightBackground` and `darkBackground` (default `tokens.color.background`); `head` for more `<head>` nodes. The dark fill follows `prefers-color-scheme` and the `html.dark` class.
171
+
172
+ ## Auth (0.7.0)
173
+
174
+ `@vvedantb/native-ui/auth` has the Clerk and Convex providers and a sign-in and sign-up screen. It needs `@clerk/clerk-expo`, `expo-auth-session`, `expo-web-browser` and `expo-router`, and `convex` when you pass a Convex client.
175
+
176
+ ```tsx
177
+ // app/_layout.tsx
178
+ import { useAuth } from "@clerk/clerk-expo";
179
+ import { tokenCache } from "@clerk/clerk-expo/token-cache";
180
+ import { AuthProviders } from "@vvedantb/native-ui/auth";
181
+
182
+ <AuthProviders publishableKey={publishableKey} tokenCache={tokenCache} convex={convex} useAuth={useAuth}>
183
+ <Stack screenOptions={{ headerShown: false }} />
184
+ </AuthProviders>
185
+ ```
186
+
187
+ `AuthProviders` nests `ClerkProvider`, `ConvexProviderWithClerk` (only with `convex`) and `ClerkLoaded` (with `loaded`, default `true`). Without `convex` the children sit under Clerk only. `clerk={false}` gives a plain `ConvexProvider`, for UI preview builds. The app reads its own key; the component checks nothing.
188
+
189
+ ```tsx
190
+ // app/(auth)/sign-in.tsx
191
+ import { SignInScreen } from "@vvedantb/native-ui/auth";
192
+
193
+ export default function SignIn() {
194
+ return <SignInScreen scheme="myapp" signUpHref="/sign-up" />;
195
+ }
196
+
197
+ // app/(auth)/sign-up.tsx
198
+ import { SignUpScreen } from "@vvedantb/native-ui/auth";
199
+
200
+ export default function SignUp() {
201
+ return <SignUpScreen scheme="myapp" signInHref="/sign-in" minPasswordLength={8} />;
202
+ }
203
+ ```
204
+
205
+ - Email and password, and "Continue with Google" (`useSSO`, `oauth_google`, redirect `AuthSession.makeRedirectUri({ scheme, path: redirectPath })`; `redirectPath` defaults to `"sso-callback"`). Sign-up sends an email code and checks it before the session starts.
206
+ - On success: `onSuccess()`, or `router.replace(afterAuthHref, replaceOptions)` (default `"/"`; for example `replaceOptions={{ withAnchor: true }}`).
207
+ - `title`, `subtitle`, `showGoogle`, `googleLabel`, `errorDisplay` (`"toast"`, default, needs a `<Toaster />`; or `"inline"`), `testIDPrefix` (default the mode: `sign-in-email`, `sign-in-password`, `sign-in-submit`, `sign-in-google` and the rest).
208
+ - `header` and `footer` slots for app chrome, such as a brand header or credit. The screen itself is neutral: background fill, title, subtitle, fields.
209
+ - Messages are generic ("Sign in failed", "Invalid email or password", "Could not continue with Google") and never name the auth provider. `authErrorMessage(error, fallback)` shows an API error's own text only when it does not name it.
210
+ - No forgot-password step. An app that needs one keeps its own screen.
211
+
212
+ `GoogleSignInButton` (`scheme`, `redirectPath`, `afterAuthHref`, `replaceOptions`, `onSuccess`, `onError`, `label` and the `Button` props), `AuthScreen`, `OrDivider` and `FieldError` build custom screens. The module calls `WebBrowser.maybeCompleteAuthSession()` once when it loads, so the app no longer needs its own call.
213
+
139
214
  ## Theme
140
215
 
141
216
  Wrap the tree (inside your colour-scheme provider):
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
- "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts and the family app marks.",
5
+ "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
6
6
  "type": "module",
7
7
  "publishConfig": {
8
8
  "access": "public"
@@ -93,6 +93,19 @@
93
93
  "types": "./src/haptics.ts",
94
94
  "default": "./src/haptics.ts"
95
95
  },
96
+ "./auth": {
97
+ "types": "./src/entries/auth.ts",
98
+ "default": "./src/entries/auth.ts"
99
+ },
100
+ "./expo-web-root": {
101
+ "types": "./src/entries/expo-web-root.ts",
102
+ "default": "./src/entries/expo-web-root.ts"
103
+ },
104
+ "./tailwind-preset": {
105
+ "types": "./src/tailwind-preset.d.cts",
106
+ "require": "./src/tailwind-preset.cjs",
107
+ "default": "./src/tailwind-preset.cjs"
108
+ },
96
109
  "./package.json": "./package.json"
97
110
  },
98
111
  "types": "./src/index.ts",
@@ -108,6 +121,7 @@
108
121
  "prepack": "node -e \"require('node:fs').accessSync('src/index.ts')\""
109
122
  },
110
123
  "peerDependencies": {
124
+ "@clerk/clerk-expo": "*",
111
125
  "@expo/vector-icons": "*",
112
126
  "@gorhom/bottom-sheet": ">=5",
113
127
  "@react-native-community/datetimepicker": "*",
@@ -117,11 +131,14 @@
117
131
  "@tabler/icons-react-native": "*",
118
132
  "class-variance-authority": ">=0.7.0",
119
133
  "clsx": ">=2.0.0",
134
+ "convex": "*",
120
135
  "dayjs": ">=1.11.0",
136
+ "expo-auth-session": "*",
121
137
  "expo-blur": "*",
122
138
  "expo-haptics": "*",
123
139
  "expo-linear-gradient": "*",
124
140
  "expo-router": "*",
141
+ "expo-web-browser": "*",
125
142
  "lucide-react-native": "*",
126
143
  "nativewind": ">=4.0.0",
127
144
  "react": ">=19.0.0",
@@ -133,15 +150,24 @@
133
150
  "tailwind-merge": ">=2.0.0"
134
151
  },
135
152
  "peerDependenciesMeta": {
153
+ "@clerk/clerk-expo": {
154
+ "optional": true
155
+ },
136
156
  "@gorhom/bottom-sheet": {
137
157
  "optional": true
138
158
  },
139
159
  "@react-native-community/datetimepicker": {
140
160
  "optional": true
141
161
  },
162
+ "convex": {
163
+ "optional": true
164
+ },
142
165
  "dayjs": {
143
166
  "optional": true
144
167
  },
168
+ "expo-auth-session": {
169
+ "optional": true
170
+ },
145
171
  "expo-blur": {
146
172
  "optional": true
147
173
  },
@@ -154,6 +180,9 @@
154
180
  "expo-router": {
155
181
  "optional": true
156
182
  },
183
+ "expo-web-browser": {
184
+ "optional": true
185
+ },
157
186
  "react-native-svg": {
158
187
  "optional": true
159
188
  }
@@ -0,0 +1,81 @@
1
+ import type { ReactNode } from "react";
2
+ import { ScrollViewStyleReset } from "expo-router/html";
3
+ import { tokens } from "./tokens";
4
+
5
+ /** `"native"` stops pinch-zoom so the page acts like the app; `"accessible"` allows it (WCAG 1.4.4). Both cover the notch. */
6
+ export type ExpoWebRootViewport = "native" | "accessible";
7
+
8
+ export const EXPO_WEB_VIEWPORT: Record<ExpoWebRootViewport, string> = {
9
+ native: "width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover",
10
+ accessible: "width=device-width, initial-scale=1.0, viewport-fit=cover",
11
+ };
12
+
13
+ export type ExpoWebRootProps = {
14
+ children: ReactNode;
15
+ /** `<html lang>`. Default `"en"`. */
16
+ lang?: string;
17
+ /** Default `"native"`. */
18
+ viewport?: ExpoWebRootViewport;
19
+ /** `ScrollViewStyleReset`: no body scroll, so `ScrollView` works as on native. Default `true`. */
20
+ scrollReset?: boolean;
21
+ /** Body fill in light. Default `tokens.color.background.light`. */
22
+ lightBackground?: string;
23
+ /** Body fill in dark. Default `tokens.color.background.dark`. */
24
+ darkBackground?: string;
25
+ /** More `<head>` nodes, after the defaults. */
26
+ head?: ReactNode;
27
+ };
28
+
29
+ /**
30
+ * The body fill as raw CSS, so the background does not flash in dark mode
31
+ * before the app paints. Dark follows the system, and the NativeWind `dark`
32
+ * class on `<html>` when the app sets the scheme (`darkMode: "class"`).
33
+ */
34
+ export function expoWebBackgroundCss(light: string, dark: string): string {
35
+ return `
36
+ body {
37
+ background-color: ${light};
38
+ }
39
+ @media (prefers-color-scheme: dark) {
40
+ body {
41
+ background-color: ${dark};
42
+ }
43
+ }
44
+ html.dark body {
45
+ background-color: ${dark};
46
+ }`;
47
+ }
48
+
49
+ /**
50
+ * The root HTML for Expo web static rendering. Use it as the whole of
51
+ * `app/+html.tsx`:
52
+ *
53
+ * export default function Root({ children }: { children: React.ReactNode }) {
54
+ * return <ExpoWebRoot>{children}</ExpoWebRoot>;
55
+ * }
56
+ *
57
+ * Runs in Node during the static render, with no DOM.
58
+ */
59
+ export function ExpoWebRoot({
60
+ children,
61
+ lang = "en",
62
+ viewport = "native",
63
+ scrollReset = true,
64
+ lightBackground = tokens.color.background.light,
65
+ darkBackground = tokens.color.background.dark,
66
+ head,
67
+ }: ExpoWebRootProps) {
68
+ return (
69
+ <html lang={lang}>
70
+ <head>
71
+ <meta charSet="utf-8" />
72
+ <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
73
+ <meta name="viewport" content={EXPO_WEB_VIEWPORT[viewport]} />
74
+ {scrollReset ? <ScrollViewStyleReset /> : null}
75
+ <style dangerouslySetInnerHTML={{ __html: expoWebBackgroundCss(lightBackground, darkBackground) }} />
76
+ {head}
77
+ </head>
78
+ <body>{children}</body>
79
+ </html>
80
+ );
81
+ }
@@ -27,7 +27,7 @@ export type CodeBlockCopyButtonProps = {
27
27
  /** Ghost copy button: the copy icon, then a check for `timeout` after a copy. */
28
28
  export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code" }: CodeBlockCopyButtonProps) {
29
29
  const [copied, setCopied] = useState(false);
30
- const timer = useRef<number | undefined>(undefined);
30
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
31
31
 
32
32
  useEffect(() => () => clearTimeout(timer.current), []);
33
33
 
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react";
2
+ import { ClerkLoaded, ClerkProvider, useAuth as useClerkAuth, type TokenCache } from "@clerk/clerk-expo";
3
+ import { ConvexProvider, type ConvexReactClient } from "convex/react";
4
+ import { ConvexProviderWithClerk } from "convex/react-clerk";
5
+
6
+ export type AuthProvidersProps = {
7
+ children: ReactNode;
8
+ /** The Clerk publishable key. The app reads it from its own config; this component does not check it. */
9
+ publishableKey?: string;
10
+ /** `tokenCache` from `@clerk/clerk-expo/token-cache`, so the session survives a restart. */
11
+ tokenCache?: TokenCache;
12
+ /** A Convex client. Without one, the children sit under Clerk only (vmem). */
13
+ convex?: ConvexReactClient | null;
14
+ /** `useAuth` from `@clerk/clerk-expo`, for `ConvexProviderWithClerk`. Default: the one this package imports. */
15
+ useAuth?: typeof useClerkAuth;
16
+ /** Wrap the children in `ClerkLoaded`, so they wait for the session. Default `true`. */
17
+ loaded?: boolean;
18
+ /**
19
+ * Default `true`. `false` drops Clerk: the children sit under a plain
20
+ * `ConvexProvider` (or nothing without `convex`), as the web
21
+ * `ConvexClerkProvider` does for UI preview builds.
22
+ */
23
+ clerk?: boolean;
24
+ };
25
+
26
+ /**
27
+ * The Clerk and Convex providers from the apps' root `_layout.tsx`:
28
+ * `ClerkProvider` › `ConvexProviderWithClerk` (with `convex`) › `ClerkLoaded`
29
+ * (with `loaded`) › children.
30
+ */
31
+ export function AuthProviders({
32
+ children,
33
+ publishableKey,
34
+ tokenCache,
35
+ convex,
36
+ useAuth = useClerkAuth,
37
+ loaded = true,
38
+ clerk = true,
39
+ }: AuthProvidersProps) {
40
+ if (!clerk) {
41
+ return convex ? <ConvexProvider client={convex}>{children}</ConvexProvider> : <>{children}</>;
42
+ }
43
+ const inner = loaded ? <ClerkLoaded>{children}</ClerkLoaded> : children;
44
+ return (
45
+ <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
46
+ {convex ? (
47
+ <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
48
+ {inner}
49
+ </ConvexProviderWithClerk>
50
+ ) : (
51
+ inner
52
+ )}
53
+ </ClerkProvider>
54
+ );
55
+ }
@@ -0,0 +1,97 @@
1
+ import type { ReactNode } from "react";
2
+ import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
3
+ import { cn } from "../cn";
4
+ import { textStyle, typeStyle } from "../controlStyles";
5
+ import { palette } from "../palette";
6
+ import { tokens } from "../tokens";
7
+ import { useScheme } from "../useScheme";
8
+
9
+ export type AuthScreenProps = {
10
+ title: string;
11
+ subtitle?: string;
12
+ children: ReactNode;
13
+ /** Above the title, such as an app mark. App brand chrome goes here. */
14
+ header?: ReactNode;
15
+ /** Below the form, such as the "Don't have an account?" row. */
16
+ footer?: ReactNode;
17
+ testID?: string;
18
+ className?: string;
19
+ };
20
+
21
+ /**
22
+ * The sign-in and sign-up frame: a keyboard-avoiding scroll view on the
23
+ * background colour, with a title, subtitle and the form in a column of 16.
24
+ */
25
+ export function AuthScreen({ title, subtitle, children, header, footer, testID, className }: AuthScreenProps) {
26
+ const scheme = useScheme();
27
+ return (
28
+ <KeyboardAvoidingView
29
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
30
+ className="flex-1"
31
+ style={{ backgroundColor: tokens.color.background[scheme] }}
32
+ >
33
+ <ScrollView
34
+ testID={testID}
35
+ className="flex-1"
36
+ contentContainerClassName={cn("grow justify-center px-6 py-12", className)}
37
+ keyboardShouldPersistTaps="handled"
38
+ >
39
+ <View className="w-full max-w-sm self-center gap-6">
40
+ {header}
41
+ <View className="gap-2">
42
+ <Text
43
+ accessibilityRole="header"
44
+ className="font-instrument-sans-semibold text-2xl"
45
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
46
+ >
47
+ {title}
48
+ </Text>
49
+ {subtitle ? (
50
+ <Text className="font-instrument-sans text-base" style={textStyle("muted", scheme)}>
51
+ {subtitle}
52
+ </Text>
53
+ ) : null}
54
+ </View>
55
+ <View className="gap-4">{children}</View>
56
+ {footer ? <View className="flex-row flex-wrap items-center justify-center gap-1">{footer}</View> : null}
57
+ </View>
58
+ </ScrollView>
59
+ </KeyboardAvoidingView>
60
+ );
61
+ }
62
+
63
+ /** A separator, "or", a separator: between Google and the email fields. */
64
+ export function OrDivider({ label = "or" }: { label?: string }) {
65
+ const scheme = useScheme();
66
+ const line = { height: StyleSheet.hairlineWidth, backgroundColor: tokens.color.separator[scheme] };
67
+ return (
68
+ <View
69
+ className="flex-row items-center gap-3"
70
+ accessibilityElementsHidden
71
+ importantForAccessibility="no-hide-descendants"
72
+ >
73
+ <View className="flex-1" style={line} />
74
+ <Text className="font-instrument-sans text-sm" style={textStyle("muted", scheme)}>
75
+ {label}
76
+ </Text>
77
+ <View className="flex-1" style={line} />
78
+ </View>
79
+ );
80
+ }
81
+
82
+ /** An inline error under a form, read out when it appears. Nothing without a message. */
83
+ export function FieldError({ message, testID }: { message?: string | null; testID?: string }) {
84
+ const scheme = useScheme();
85
+ if (!message) return null;
86
+ return (
87
+ <Text
88
+ testID={testID}
89
+ accessibilityLiveRegion="polite"
90
+ accessibilityRole="alert"
91
+ className="font-instrument-sans text-sm"
92
+ style={{ color: palette.danger[scheme] }}
93
+ >
94
+ {message}
95
+ </Text>
96
+ );
97
+ }
@@ -0,0 +1,116 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Platform } from "react-native";
3
+ import { useSSO } from "@clerk/clerk-expo";
4
+ import * as AuthSession from "expo-auth-session";
5
+ import * as WebBrowser from "expo-web-browser";
6
+ import { useRouter } from "expo-router";
7
+ import { GoogleMark } from "../brand/GoogleMark";
8
+ import { Button, type ButtonProps } from "../Button";
9
+ import { toast } from "../toastStore";
10
+ import { authErrorMessage } from "./authErrors";
11
+
12
+ // Completes the Google browser session when the app reopens on the redirect.
13
+ WebBrowser.maybeCompleteAuthSession();
14
+
15
+ export const GOOGLE_ERROR_MESSAGE = "Could not continue with Google";
16
+
17
+ export type AuthNavigationOptions = {
18
+ /** Where to go after a session starts. Default `"/"`. */
19
+ afterAuthHref?: string;
20
+ /** Passed to `router.replace`, such as `{ withAnchor: true }`. */
21
+ replaceOptions?: object;
22
+ /** Called after a session starts, in place of `router.replace`. */
23
+ onSuccess?: () => void;
24
+ };
25
+
26
+ export type GoogleSignInOptions = AuthNavigationOptions & {
27
+ /** The app URL scheme, such as `"vibot"`, for the redirect `scheme://sso-callback`. */
28
+ scheme: string;
29
+ /** Default `"sso-callback"`. */
30
+ redirectPath?: string;
31
+ /** Called with a message for the user. Default: an error toast. */
32
+ onError?: (message: string) => void;
33
+ };
34
+
35
+ /** Warms the Android custom tab while the screen is up, as the Expo guide suggests. */
36
+ export function useWarmUpBrowser() {
37
+ useEffect(() => {
38
+ if (Platform.OS !== "android") return;
39
+ void WebBrowser.warmUpAsync().catch(() => {});
40
+ return () => {
41
+ void WebBrowser.coolDownAsync().catch(() => {});
42
+ };
43
+ }, []);
44
+ }
45
+
46
+ /** Starts the Google flow, sets the session and navigates. Returns `[start, busy]`. */
47
+ export function useGoogleSignIn({
48
+ scheme,
49
+ redirectPath = "sso-callback",
50
+ afterAuthHref = "/",
51
+ replaceOptions,
52
+ onSuccess,
53
+ onError,
54
+ }: GoogleSignInOptions): [() => Promise<void>, boolean] {
55
+ const { startSSOFlow } = useSSO();
56
+ const router = useRouter();
57
+ const [busy, setBusy] = useState(false);
58
+ const start = async () => {
59
+ if (busy) return;
60
+ setBusy(true);
61
+ try {
62
+ const { createdSessionId, setActive } = await startSSOFlow({
63
+ strategy: "oauth_google",
64
+ redirectUrl: AuthSession.makeRedirectUri({ scheme, path: redirectPath }),
65
+ });
66
+ // No session: the user closed the browser, or more steps are needed.
67
+ if (createdSessionId && setActive) {
68
+ await setActive({ session: createdSessionId });
69
+ if (onSuccess) onSuccess();
70
+ else router.replace(afterAuthHref, replaceOptions);
71
+ }
72
+ } catch (error) {
73
+ const message = authErrorMessage(error, GOOGLE_ERROR_MESSAGE);
74
+ if (onError) onError(message);
75
+ else toast.error(message);
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ };
80
+ return [start, busy];
81
+ }
82
+
83
+ export type GoogleSignInButtonProps = GoogleSignInOptions &
84
+ Omit<ButtonProps, "onPress" | "children" | "leftIcon" | "loading"> & {
85
+ /** Default "Continue with Google". */
86
+ label?: string;
87
+ };
88
+
89
+ /** An outline button with the Google mark that runs `useGoogleSignIn`. */
90
+ export function GoogleSignInButton({
91
+ scheme,
92
+ redirectPath,
93
+ afterAuthHref,
94
+ replaceOptions,
95
+ onSuccess,
96
+ onError,
97
+ label = "Continue with Google",
98
+ testID = "google-sign-in",
99
+ variant = "outline",
100
+ ...props
101
+ }: GoogleSignInButtonProps) {
102
+ useWarmUpBrowser();
103
+ const [start, busy] = useGoogleSignIn({ scheme, redirectPath, afterAuthHref, replaceOptions, onSuccess, onError });
104
+ return (
105
+ <Button
106
+ {...props}
107
+ testID={testID}
108
+ variant={variant}
109
+ leftIcon={<GoogleMark />}
110
+ loading={busy}
111
+ onPress={() => void start()}
112
+ >
113
+ {label}
114
+ </Button>
115
+ );
116
+ }
@@ -0,0 +1,302 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useSignIn, useSignUp } from "@clerk/clerk-expo";
4
+ import { useRouter } from "expo-router";
5
+ import { Button } from "../Button";
6
+ import { textStyle } from "../controlStyles";
7
+ import { Input } from "../Input";
8
+ import { toast } from "../toastStore";
9
+ import { useScheme } from "../useScheme";
10
+ import { AuthScreen, FieldError, OrDivider } from "./AuthScreen";
11
+ import { authErrorMessage } from "./authErrors";
12
+ import { GoogleSignInButton, type AuthNavigationOptions } from "./GoogleSignInButton";
13
+
14
+ export type SignInMode = "sign-in" | "sign-up";
15
+
16
+ /** The messages the screen can show. Generic: none names a provider. */
17
+ export const AUTH_MESSAGES = {
18
+ missingFields: "Enter your email and password",
19
+ signInFailed: "Sign in failed",
20
+ invalidCredentials: "Invalid email or password",
21
+ signUpFailed: "Could not create account",
22
+ verifyFailed: "Invalid code",
23
+ missingCode: "Enter the code from the email",
24
+ needsAnotherStep: "This account needs another step to sign in. Finish it on the web.",
25
+ } as const;
26
+
27
+ export type SignInScreenProps = AuthNavigationOptions & {
28
+ /** Default `"sign-in"`. `"sign-up"` creates the account and checks an emailed code. */
29
+ mode?: SignInMode;
30
+ /** Default "Welcome back", or "Create account" in `sign-up`. */
31
+ title?: string;
32
+ /** Default "Sign in to continue", or "Sign up to get started" in `sign-up`. */
33
+ subtitle?: string;
34
+ /** The app URL scheme, such as `"vibot"`, for the Google redirect `scheme://sso-callback`. */
35
+ scheme: string;
36
+ /** Default `"sso-callback"`. */
37
+ redirectPath?: string;
38
+ /** Default `true`. */
39
+ showGoogle?: boolean;
40
+ /** Default "Continue with Google". */
41
+ googleLabel?: string;
42
+ /** In `sign-in`: the "Sign up" link in the footer. No link without it. */
43
+ signUpHref?: string;
44
+ /** In `sign-up`: the "Sign in" link in the footer. No link without it. */
45
+ signInHref?: string;
46
+ /** Sign-up rejects a shorter password before sending it. Default: no check. */
47
+ minPasswordLength?: number;
48
+ /** Default `"toast"`. `"inline"` shows errors under the fields. */
49
+ errorDisplay?: "toast" | "inline";
50
+ /** Prefix for every `testID`: `sign-in-email`, `sign-in-password`, `sign-in-submit` and the rest. Default the mode. */
51
+ testIDPrefix?: string;
52
+ /** Above the title. App brand chrome goes here. */
53
+ header?: ReactNode;
54
+ /** Below the footer link row, such as an app credit. */
55
+ footer?: ReactNode;
56
+ };
57
+
58
+ /**
59
+ * Email and password, and Google, sign-in or sign-up. Sign-up sends an email
60
+ * code and checks it before the session starts. On success it calls
61
+ * `onSuccess`, or `router.replace(afterAuthHref, replaceOptions)`.
62
+ *
63
+ * Forgot-password is not here: an app that needs it keeps its own screen.
64
+ */
65
+ export function SignInScreen({
66
+ mode = "sign-in",
67
+ title,
68
+ subtitle,
69
+ scheme,
70
+ redirectPath,
71
+ afterAuthHref = "/",
72
+ replaceOptions,
73
+ onSuccess,
74
+ showGoogle = true,
75
+ googleLabel,
76
+ signUpHref,
77
+ signInHref,
78
+ minPasswordLength,
79
+ errorDisplay = "toast",
80
+ testIDPrefix,
81
+ header,
82
+ footer,
83
+ }: SignInScreenProps) {
84
+ const signInState = useSignIn();
85
+ const signUpState = useSignUp();
86
+ const router = useRouter();
87
+ const colorScheme = useScheme();
88
+ const [email, setEmail] = useState("");
89
+ const [password, setPassword] = useState("");
90
+ const [code, setCode] = useState("");
91
+ const [pendingVerification, setPendingVerification] = useState(false);
92
+ const [error, setError] = useState("");
93
+ const [submitting, setSubmitting] = useState(false);
94
+ const signUp = mode === "sign-up";
95
+ const id = (name: string) => `${testIDPrefix ?? mode}-${name}`;
96
+
97
+ const fail = (heading: string, message: string) => {
98
+ if (errorDisplay === "inline") setError(message);
99
+ else toast.error(heading, message === heading ? undefined : { description: message });
100
+ };
101
+
102
+ const done = () => {
103
+ if (onSuccess) onSuccess();
104
+ else router.replace(afterAuthHref, replaceOptions);
105
+ };
106
+
107
+ const onSignIn = async () => {
108
+ const { isLoaded, signIn, setActive } = signInState;
109
+ if (!isLoaded || !signIn || !setActive || submitting) return;
110
+ if (!email || !password) return fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.missingFields);
111
+ setSubmitting(true);
112
+ setError("");
113
+ try {
114
+ const attempt = await signIn.create({ identifier: email, password });
115
+ if (attempt.status === "complete" && attempt.createdSessionId) {
116
+ await setActive({ session: attempt.createdSessionId });
117
+ done();
118
+ } else {
119
+ fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.needsAnotherStep);
120
+ }
121
+ } catch (caught) {
122
+ fail(AUTH_MESSAGES.signInFailed, authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials));
123
+ } finally {
124
+ setSubmitting(false);
125
+ }
126
+ };
127
+
128
+ const onSignUp = async () => {
129
+ const { isLoaded, signUp: attempt } = signUpState;
130
+ if (!isLoaded || !attempt || submitting) return;
131
+ if (!email || !password) return fail(AUTH_MESSAGES.signUpFailed, AUTH_MESSAGES.missingFields);
132
+ if (minPasswordLength && password.length < minPasswordLength) {
133
+ return fail(AUTH_MESSAGES.signUpFailed, `Use at least ${minPasswordLength} characters for the password`);
134
+ }
135
+ setSubmitting(true);
136
+ setError("");
137
+ try {
138
+ await attempt.create({ emailAddress: email, password });
139
+ await attempt.prepareEmailAddressVerification({ strategy: "email_code" });
140
+ setPendingVerification(true);
141
+ } catch (caught) {
142
+ fail(AUTH_MESSAGES.signUpFailed, authErrorMessage(caught, AUTH_MESSAGES.signUpFailed));
143
+ } finally {
144
+ setSubmitting(false);
145
+ }
146
+ };
147
+
148
+ const onVerify = async () => {
149
+ const { isLoaded, signUp: attempt, setActive } = signUpState;
150
+ if (!isLoaded || !attempt || !setActive || submitting) return;
151
+ if (!code) return fail(AUTH_MESSAGES.verifyFailed, AUTH_MESSAGES.missingCode);
152
+ setSubmitting(true);
153
+ setError("");
154
+ try {
155
+ const result = await attempt.attemptEmailAddressVerification({ code });
156
+ if (result.status === "complete" && result.createdSessionId) {
157
+ await setActive({ session: result.createdSessionId });
158
+ done();
159
+ } else {
160
+ fail(AUTH_MESSAGES.verifyFailed, AUTH_MESSAGES.needsAnotherStep);
161
+ }
162
+ } catch (caught) {
163
+ fail(AUTH_MESSAGES.verifyFailed, authErrorMessage(caught, AUTH_MESSAGES.verifyFailed));
164
+ } finally {
165
+ setSubmitting(false);
166
+ }
167
+ };
168
+
169
+ const linkHref = signUp ? signInHref : signUpHref;
170
+ const footerRow = (
171
+ <>
172
+ {linkHref && !pendingVerification ? (
173
+ <>
174
+ <Text className="font-instrument-sans text-sm" style={textStyle("muted", colorScheme)}>
175
+ {signUp ? "Already have an account?" : "Don't have an account?"}
176
+ </Text>
177
+ <Button
178
+ testID={id(signUp ? "sign-in-link" : "sign-up-link")}
179
+ variant="link"
180
+ size="sm"
181
+ fullWidth={false}
182
+ onPress={() => router.push(linkHref)}
183
+ >
184
+ {signUp ? "Sign in" : "Sign up"}
185
+ </Button>
186
+ </>
187
+ ) : null}
188
+ {footer}
189
+ </>
190
+ );
191
+ const hasFooter = Boolean((linkHref && !pendingVerification) || footer);
192
+
193
+ if (pendingVerification) {
194
+ return (
195
+ <AuthScreen
196
+ testID={id("screen")}
197
+ title="Check your email"
198
+ subtitle="Enter the code we sent to your email."
199
+ header={header}
200
+ footer={hasFooter ? footerRow : undefined}
201
+ >
202
+ <Input
203
+ testID={id("code")}
204
+ accessibilityLabel="Verification code"
205
+ value={code}
206
+ placeholder="Verification code"
207
+ keyboardType="number-pad"
208
+ autoComplete="one-time-code"
209
+ textContentType="oneTimeCode"
210
+ autoFocus
211
+ returnKeyType="go"
212
+ onChangeText={setCode}
213
+ onSubmitEditing={() => void onVerify()}
214
+ />
215
+ <FieldError testID={id("error")} message={error} />
216
+ <Button testID={id("verify")} loading={submitting} onPress={() => void onVerify()}>
217
+ Verify
218
+ </Button>
219
+ <Button
220
+ testID={id("back")}
221
+ variant="ghost"
222
+ onPress={() => {
223
+ setPendingVerification(false);
224
+ setCode("");
225
+ setError("");
226
+ }}
227
+ >
228
+ Use a different email
229
+ </Button>
230
+ </AuthScreen>
231
+ );
232
+ }
233
+
234
+ return (
235
+ <AuthScreen
236
+ testID={id("screen")}
237
+ title={title ?? (signUp ? "Create account" : "Welcome back")}
238
+ subtitle={subtitle ?? (signUp ? "Sign up to get started" : "Sign in to continue")}
239
+ header={header}
240
+ footer={hasFooter ? footerRow : undefined}
241
+ >
242
+ {showGoogle ? (
243
+ <>
244
+ <GoogleSignInButton
245
+ testID={id("google")}
246
+ scheme={scheme}
247
+ redirectPath={redirectPath}
248
+ afterAuthHref={afterAuthHref}
249
+ replaceOptions={replaceOptions}
250
+ onSuccess={onSuccess}
251
+ onError={(message) => fail(message, message)}
252
+ label={googleLabel}
253
+ disabled={submitting}
254
+ />
255
+ <OrDivider />
256
+ </>
257
+ ) : null}
258
+ <Input
259
+ testID={id("email")}
260
+ accessibilityLabel="Email"
261
+ autoCapitalize="none"
262
+ autoComplete="email"
263
+ textContentType="emailAddress"
264
+ keyboardType="email-address"
265
+ returnKeyType="next"
266
+ value={email}
267
+ placeholder="Email"
268
+ onChangeText={setEmail}
269
+ />
270
+ <Input
271
+ testID={id("password")}
272
+ accessibilityLabel="Password"
273
+ autoCapitalize="none"
274
+ autoComplete={signUp ? "new-password" : "password"}
275
+ textContentType={signUp ? "newPassword" : "password"}
276
+ secureTextEntry
277
+ returnKeyType="go"
278
+ value={password}
279
+ placeholder="Password"
280
+ onChangeText={setPassword}
281
+ onSubmitEditing={() => void (signUp ? onSignUp() : onSignIn())}
282
+ />
283
+ <FieldError testID={id("error")} message={error} />
284
+ <Button
285
+ testID={id("submit")}
286
+ loading={submitting}
287
+ onPress={() => void (signUp ? onSignUp() : onSignIn())}
288
+ >
289
+ {signUp ? "Create account" : "Sign in"}
290
+ </Button>
291
+ {/* Bot protection mounts its widget here on web sign-up. */}
292
+ {signUp ? <View nativeID="clerk-captcha" /> : null}
293
+ </AuthScreen>
294
+ );
295
+ }
296
+
297
+ export type SignUpScreenProps = Omit<SignInScreenProps, "mode">;
298
+
299
+ /** `SignInScreen` with `mode="sign-up"`. */
300
+ export function SignUpScreen(props: SignUpScreenProps) {
301
+ return <SignInScreen {...props} mode="sign-up" />;
302
+ }
@@ -0,0 +1,19 @@
1
+ import { isClerkAPIResponseError } from "@clerk/clerk-expo";
2
+
3
+ /** Words that must not reach the user: the auth provider and stack names. */
4
+ const PRODUCT_NAMES = /\b(clerk|convex)\b/i;
5
+
6
+ /**
7
+ * A message to show for an auth error. An API error gives its first
8
+ * `longMessage` or `message` (such as "Password is incorrect."); anything
9
+ * else, or a message that names the provider, gives `fallback`. Never shows
10
+ * a raw exception message.
11
+ */
12
+ export function authErrorMessage(error: unknown, fallback: string): string {
13
+ if (isClerkAPIResponseError(error)) {
14
+ const first = error.errors[0];
15
+ const message = first?.longMessage ?? first?.message;
16
+ if (message && !PRODUCT_NAMES.test(message)) return message;
17
+ }
18
+ return fallback;
19
+ }
@@ -0,0 +1,22 @@
1
+ // Clerk + Convex providers and the shared sign-in and sign-up screens. On
2
+ // `@vvedantb/native-ui/auth` only: they import `@clerk/clerk-expo`, `convex`,
3
+ // `expo-auth-session`, `expo-web-browser` and `expo-router`, and Metro does not
4
+ // tree-shake.
5
+ export { AuthProviders } from "./AuthProviders";
6
+ export type { AuthProvidersProps } from "./AuthProviders";
7
+ export { AuthScreen, OrDivider, FieldError } from "./AuthScreen";
8
+ export type { AuthScreenProps } from "./AuthScreen";
9
+ export { authErrorMessage } from "./authErrors";
10
+ export {
11
+ GoogleSignInButton,
12
+ useGoogleSignIn,
13
+ useWarmUpBrowser,
14
+ GOOGLE_ERROR_MESSAGE,
15
+ } from "./GoogleSignInButton";
16
+ export type {
17
+ AuthNavigationOptions,
18
+ GoogleSignInOptions,
19
+ GoogleSignInButtonProps,
20
+ } from "./GoogleSignInButton";
21
+ export { SignInScreen, SignUpScreen, AUTH_MESSAGES } from "./SignInScreen";
22
+ export type { SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
@@ -0,0 +1,5 @@
1
+ // `@vvedantb/native-ui/auth`: `AuthProviders`, `SignInScreen`, `SignUpScreen`,
2
+ // `GoogleSignInButton` and the auth frame. Not on the root entry: it imports
3
+ // `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and
4
+ // `expo-router`, and Metro does not tree-shake.
5
+ export * from "../auth";
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/expo-web-root`: `ExpoWebRoot` for `app/+html.tsx`. Not on
2
+ // the root entry: it imports `expo-router/html`, and Metro does not tree-shake.
3
+ export { ExpoWebRoot, EXPO_WEB_VIEWPORT, expoWebBackgroundCss } from "../ExpoWebRoot";
4
+ export type { ExpoWebRootProps, ExpoWebRootViewport } from "../ExpoWebRoot";
@@ -0,0 +1,248 @@
1
+ /**
2
+ * `@vvedantb/native-ui/tailwind-preset`: the NativeWind / Tailwind preset the
3
+ * Expo apps share. CommonJS, because `tailwind.config.js` `require`s it and
4
+ * Tailwind loads it outside Metro.
5
+ *
6
+ * const { nativeUiPreset, nativeUiContent } = require("@vvedantb/native-ui/tailwind-preset");
7
+ * module.exports = {
8
+ * content: ["./app/**\/*.{js,ts,tsx}", nativeUiContent()],
9
+ * presets: [nativeUiPreset()],
10
+ * };
11
+ *
12
+ * It nests `nativewind/preset`, so callers pass only this one. It sets
13
+ * `darkMode`, the Instrument Sans font classes, the colour keys native-ui
14
+ * classes use, `borderWidth.hairline` and, when asked, `borderRadius` from
15
+ * `--radius`.
16
+ * App colours, display fonts, keyframes and font-weight plugins stay in the
17
+ * app config.
18
+ *
19
+ * `color` maps a CSS variable name (without `--`) to a Tailwind colour, as the
20
+ * web `tokensPreset({ color })` of `@vvedantb/ui`. With `injectCssVariables`,
21
+ * the preset also writes those variables (light and dark) from `tokens.ts` and
22
+ * `palette.ts`, as space-separated RGB channels, and the default `color` is
23
+ * `rgbAlpha`.
24
+ */
25
+ "use strict";
26
+
27
+ const fs = require("node:fs");
28
+ const path = require("node:path");
29
+
30
+ /** `var(--name)`. */
31
+ const cssVar = (name) => `var(--${name})`;
32
+ /** `hsl(var(--name))`, for variables that hold `h s% l%` channels. */
33
+ const hslVar = (name) => `hsl(var(--${name}))`;
34
+ /** `rgb(var(--name) / <alpha-value>)`, for variables that hold `r g b` channels. Keeps opacity modifiers. */
35
+ const rgbAlpha = (name) => `rgb(var(--${name}) / <alpha-value>)`;
36
+
37
+ const INSTRUMENT_SANS = {
38
+ regular: "InstrumentSans_400Regular",
39
+ medium: "InstrumentSans_500Medium",
40
+ semibold: "InstrumentSans_600SemiBold",
41
+ bold: "InstrumentSans_700Bold",
42
+ };
43
+
44
+ /** The glob Tailwind scans for native-ui class names: this package's `src`, at its real path (pnpm symlinks resolve). */
45
+ function nativeUiContent() {
46
+ return path.join(__dirname, "**/*.{js,ts,tsx}").split(path.sep).join("/");
47
+ }
48
+
49
+ /**
50
+ * Reads one `export const name = { … } as const;` object from a sibling
51
+ * `.ts` file. Plain object literals only, so no TypeScript loader is needed:
52
+ * `tokens.ts` and `palette.ts` stay the single source of the colours.
53
+ */
54
+ function readConst(file, name) {
55
+ const source = fs.readFileSync(path.join(__dirname, file), "utf8");
56
+ const match = source.match(new RegExp(`export const ${name} = (\\{[\\s\\S]*?\\n\\}) as const;`));
57
+ if (!match) throw new Error(`@vvedantb/native-ui/tailwind-preset: no \`${name}\` object in ${file}`);
58
+ return new Function(`return (${match[1]});`)();
59
+ }
60
+
61
+ let cachedSchemeColors = null;
62
+
63
+ /** CSS variable name → `{ light, dark }` hex, from `tokens.ts` and `palette.ts`. */
64
+ function schemeColors() {
65
+ if (cachedSchemeColors) return cachedSchemeColors;
66
+ const tokens = readConst("tokens.ts", "tokens");
67
+ const palette = readConst("palette.ts", "palette");
68
+ const c = tokens.color;
69
+ cachedSchemeColors = {
70
+ background: c.background,
71
+ foreground: c.foreground,
72
+ border: c.border,
73
+ input: c.border,
74
+ ring: palette.primary,
75
+ separator: c.separator,
76
+ surface: c.surface,
77
+ "surface-foreground": c.foreground,
78
+ "surface-secondary": c.surfaceSecondary,
79
+ "surface-tertiary": palette.surfaceTertiary,
80
+ "surface-card": c.surfaceCard,
81
+ muted: c.muted,
82
+ "muted-foreground": c.muted,
83
+ primary: palette.primary,
84
+ "primary-foreground": palette.primaryForeground,
85
+ secondary: palette.default,
86
+ "secondary-foreground": palette.defaultForeground,
87
+ destructive: palette.danger,
88
+ "destructive-foreground": palette.dangerForeground,
89
+ accent: palette.primary,
90
+ "accent-foreground": palette.primaryForeground,
91
+ popover: palette.overlay,
92
+ "popover-foreground": palette.overlayForeground,
93
+ card: c.surfaceCard,
94
+ "card-foreground": c.foreground,
95
+ danger: palette.danger,
96
+ "danger-foreground": palette.dangerForeground,
97
+ success: palette.success,
98
+ "success-foreground": palette.successForeground,
99
+ warning: palette.warning,
100
+ "warning-foreground": palette.warningForeground,
101
+ default: palette.default,
102
+ "default-foreground": palette.defaultForeground,
103
+ overlay: palette.overlay,
104
+ "overlay-foreground": palette.overlayForeground,
105
+ field: c.surface,
106
+ "field-placeholder": palette.fieldPlaceholder,
107
+ };
108
+ return cachedSchemeColors;
109
+ }
110
+
111
+ /** `#rrggbb` as `r g b`. */
112
+ function channels(hex) {
113
+ const n = Number.parseInt(hex.slice(1, 7), 16);
114
+ return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
115
+ }
116
+
117
+ /** `--name: r g b` for every colour in one scheme. */
118
+ function cssVariables(scheme) {
119
+ return Object.fromEntries(
120
+ Object.entries(schemeColors()).map(([name, value]) => [`--${name}`, channels(value[scheme])]),
121
+ );
122
+ }
123
+
124
+ /** `--radius` from `tokens.radius.lg` (16), so `rounded-lg`, `-md` and `-sm` are 16, 14 and 12 as in `tokens.radius`. */
125
+ function radiusVariable() {
126
+ return { "--radius": `${readConst("tokens.ts", "tokens").radius.lg}px` };
127
+ }
128
+
129
+ /** The Tailwind colour keys native-ui classes and the apps share. */
130
+ function themeColors(v) {
131
+ return {
132
+ background: v("background"),
133
+ foreground: v("foreground"),
134
+ border: v("border"),
135
+ input: v("input"),
136
+ ring: v("ring"),
137
+ separator: v("separator"),
138
+ surface: {
139
+ DEFAULT: v("surface"),
140
+ foreground: v("surface-foreground"),
141
+ secondary: v("surface-secondary"),
142
+ tertiary: v("surface-tertiary"),
143
+ card: v("surface-card"),
144
+ },
145
+ primary: { DEFAULT: v("primary"), foreground: v("primary-foreground") },
146
+ secondary: { DEFAULT: v("secondary"), foreground: v("secondary-foreground") },
147
+ destructive: { DEFAULT: v("destructive"), foreground: v("destructive-foreground") },
148
+ muted: { DEFAULT: v("muted"), foreground: v("muted-foreground") },
149
+ accent: { DEFAULT: v("accent"), foreground: v("accent-foreground") },
150
+ popover: { DEFAULT: v("popover"), foreground: v("popover-foreground") },
151
+ card: { DEFAULT: v("card"), foreground: v("card-foreground") },
152
+ danger: { DEFAULT: v("danger"), foreground: v("danger-foreground") },
153
+ success: { DEFAULT: v("success"), foreground: v("success-foreground") },
154
+ warning: { DEFAULT: v("warning"), foreground: v("warning-foreground") },
155
+ default: { DEFAULT: v("default"), foreground: v("default-foreground") },
156
+ overlay: { DEFAULT: v("overlay"), foreground: v("overlay-foreground") },
157
+ field: { DEFAULT: v("field"), placeholder: v("field-placeholder") },
158
+ };
159
+ }
160
+
161
+ /** `nativewind/preset`, or none where NativeWind is not installed (this repository's tests). */
162
+ function nestedPresets() {
163
+ try {
164
+ return [require("nativewind/preset")];
165
+ } catch {
166
+ return [];
167
+ }
168
+ }
169
+
170
+ /** `{ hairline: hairlineWidth() }` from `nativewind/theme`, or none without NativeWind. */
171
+ function hairlineBorder() {
172
+ try {
173
+ const { hairlineWidth } = require("nativewind/theme");
174
+ return typeof hairlineWidth === "function" ? { hairline: hairlineWidth() } : {};
175
+ } catch {
176
+ return {};
177
+ }
178
+ }
179
+
180
+ /** A Tailwind plugin that writes the colour variables: `:root` (light) and the dark set for `darkMode`. */
181
+ function cssVariablesPlugin(darkMode) {
182
+ return function nativeUiCssVariables({ addBase }) {
183
+ const dark = cssVariables("dark");
184
+ addBase(
185
+ darkMode === "media"
186
+ ? {
187
+ ":root": { ...cssVariables("light"), ...radiusVariable() },
188
+ "@media (prefers-color-scheme: dark)": { ":root": dark },
189
+ }
190
+ : {
191
+ ":root": { ...cssVariables("light"), ...radiusVariable() },
192
+ ".dark:root, .dark": dark,
193
+ },
194
+ );
195
+ };
196
+ }
197
+
198
+ /**
199
+ * The preset. Options:
200
+ * - `darkMode`: `"class"` (default; `ThemeToggle` and `setColorScheme` need it) or `"media"`.
201
+ * - `color`: CSS variable name → colour value. Default `cssVar`, or `rgbAlpha` with `injectCssVariables`.
202
+ * - `injectCssVariables`: write the colour variables and `--radius` from `tokens.ts` and `palette.ts`. Default `false`.
203
+ * - `radiusVariable`: `rounded-lg`, `-md` and `-sm` from `var(--radius)`, as vibot and Velth. Default: on with
204
+ * `injectCssVariables`, else off, so an app without `--radius` keeps the Tailwind radii.
205
+ */
206
+ function nativeUiPreset(options = {}) {
207
+ const darkMode = options.darkMode === "media" ? "media" : "class";
208
+ const inject = options.injectCssVariables === true;
209
+ const color = options.color || (inject ? rgbAlpha : cssVar);
210
+ const radius = options.radiusVariable ?? inject;
211
+ return {
212
+ darkMode,
213
+ presets: nestedPresets(),
214
+ theme: {
215
+ extend: {
216
+ colors: themeColors(color),
217
+ fontFamily: {
218
+ sans: [INSTRUMENT_SANS.regular],
219
+ "instrument-sans": [INSTRUMENT_SANS.regular],
220
+ "instrument-sans-medium": [INSTRUMENT_SANS.medium],
221
+ "instrument-sans-semibold": [INSTRUMENT_SANS.semibold],
222
+ "instrument-sans-bold": [INSTRUMENT_SANS.bold],
223
+ },
224
+ borderRadius: radius
225
+ ? {
226
+ lg: "var(--radius)",
227
+ md: "calc(var(--radius) - 2px)",
228
+ sm: "calc(var(--radius) - 4px)",
229
+ }
230
+ : {},
231
+ borderWidth: hairlineBorder(),
232
+ },
233
+ },
234
+ plugins: inject ? [cssVariablesPlugin(darkMode)] : [],
235
+ };
236
+ }
237
+
238
+ module.exports = {
239
+ nativeUiPreset,
240
+ nativeUiContent,
241
+ cssVar,
242
+ hslVar,
243
+ rgbAlpha,
244
+ cssVariables,
245
+ schemeColors,
246
+ INSTRUMENT_SANS,
247
+ };
248
+ module.exports.default = nativeUiPreset();
@@ -0,0 +1,50 @@
1
+ /** Maps a CSS variable name (without `--`) to a Tailwind colour value. */
2
+ export type ColorFn = (name: string) => string;
3
+
4
+ type Colors = { [key: string]: string | Colors };
5
+ type CssDeclarations = Record<string, string>;
6
+ type AddBase = (base: Record<string, CssDeclarations | Record<string, CssDeclarations>>) => void;
7
+
8
+ export interface NativeUiPresetOptions {
9
+ /** Default `"class"`: `ThemeToggle` and NativeWind `setColorScheme` need it. */
10
+ darkMode?: "class" | "media";
11
+ /** Default `cssVar`, or `rgbAlpha` with `injectCssVariables`. */
12
+ color?: ColorFn;
13
+ /** Write the colour variables (as `r g b` channels) and `--radius` from `tokens.ts` and `palette.ts`. Default `false`. */
14
+ injectCssVariables?: boolean;
15
+ /** `rounded-lg`, `-md` and `-sm` from `var(--radius)`. Default: the value of `injectCssVariables`. */
16
+ radiusVariable?: boolean;
17
+ }
18
+
19
+ export interface NativeUiPreset {
20
+ darkMode: "class" | "media";
21
+ presets: unknown[];
22
+ theme: {
23
+ extend: {
24
+ colors: Colors;
25
+ fontFamily: Record<string, string[]>;
26
+ borderRadius: Record<string, string>;
27
+ borderWidth: Record<string, unknown>;
28
+ };
29
+ };
30
+ plugins: Array<(api: { addBase: AddBase }) => void>;
31
+ }
32
+
33
+ export declare const cssVar: ColorFn;
34
+ export declare const hslVar: ColorFn;
35
+ export declare const rgbAlpha: ColorFn;
36
+ export declare const INSTRUMENT_SANS: {
37
+ regular: "InstrumentSans_400Regular";
38
+ medium: "InstrumentSans_500Medium";
39
+ semibold: "InstrumentSans_600SemiBold";
40
+ bold: "InstrumentSans_700Bold";
41
+ };
42
+ export declare function nativeUiPreset(options?: NativeUiPresetOptions): NativeUiPreset;
43
+ /** Absolute glob of this package's `src/**\/*.{js,ts,tsx}`, for Tailwind `content`. */
44
+ export declare function nativeUiContent(): string;
45
+ /** CSS variable name → `{ light, dark }` hex, from `tokens.ts` and `palette.ts`. */
46
+ export declare function schemeColors(): Record<string, { light: string; dark: string }>;
47
+ /** `{ "--name": "r g b" }` for one scheme. */
48
+ export declare function cssVariables(scheme: "light" | "dark"): CssDeclarations;
49
+ declare const preset: NativeUiPreset;
50
+ export default preset;