@vvedantb/native-ui 0.6.1 → 0.8.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 +89 -10
- package/package.json +31 -2
- package/src/ExpoWebRoot.tsx +81 -0
- package/src/ai/code-block.tsx +1 -1
- package/src/auth/AuthProviders.tsx +55 -0
- package/src/auth/AuthScreen.tsx +97 -0
- package/src/auth/GoogleSignInButton.tsx +116 -0
- package/src/auth/SignInScreen.tsx +302 -0
- package/src/auth/authErrors.ts +19 -0
- package/src/auth/index.ts +22 -0
- package/src/entries/auth.ts +5 -0
- package/src/entries/expo-web-root.ts +4 -0
- package/src/entries/layout.ts +18 -0
- package/src/examples/ParityGallery.tsx +11 -0
- package/src/index.ts +18 -0
- package/src/settings/MoreFromProjectV.tsx +105 -0
- package/src/settings/projectVMobileApps.ts +145 -0
- package/src/tailwind-preset.cjs +248 -0
- package/src/tailwind-preset.d.cts +50 -0
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:
|
|
@@ -67,7 +68,7 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
67
68
|
| `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
|
|
68
69
|
| `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `fontClass`, `cn`, `Colors` and the colour constants | none |
|
|
69
70
|
| `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
|
|
70
|
-
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack` | `expo-blur` (loader) |
|
|
71
|
+
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `MoreFromProjectV` and `FromProjectV` (0.8.0) | `expo-blur` (loader) |
|
|
71
72
|
| `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
|
|
72
73
|
| `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
|
|
73
74
|
| `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
|
|
@@ -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.
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"./components/**/*.{js,ts,tsx}",
|
|
104
|
-
|
|
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
|
-
|
|
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):
|
|
@@ -227,6 +302,10 @@ From 0.3.12 a toast looks like the web `SonnerToaster` at its default density wi
|
|
|
227
302
|
|
|
228
303
|
`@vvedantb/native-ui/gallery` exports `ParityGallery`, a screen that renders these components. Put it in a dev-only route to check them on a device.
|
|
229
304
|
|
|
305
|
+
## More from Project V (0.8.0)
|
|
306
|
+
|
|
307
|
+
`<MoreFromProjectV current="velth" />` is a settings section listing the other Project V phone apps (vibot, Verve, Velth, vmem, V Store). A row opens the app if installed, else V Store's home (`vstore://`), else its website. Add the other apps' schemes to `LSApplicationQueriesSchemes` (iOS) and `<queries>` (Android 11+), or `canOpenURL` returns `false`. `<FromProjectV />` (or `variant="footer"`) is a small "from Project V" line. `openProjectVApp(id, Linking, Platform.OS)` and `PROJECT_V_MOBILE_APPS` are exported for custom rows.
|
|
308
|
+
|
|
230
309
|
## App pieces (0.5.0)
|
|
231
310
|
|
|
232
311
|
Components and props that the Verve, vibot and Velth apps need. They follow the rest of the package: Instrument Sans with its weight as a style, `tokens` and `palette` colours as styles in light and dark, a step up with larger text, 44 pt targets, and roles, names and states for VoiceOver and TalkBack.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
|
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
|
+
}
|
package/src/ai/code-block.tsx
CHANGED
|
@@ -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<
|
|
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
|
+
}
|