@ramonclaudio/create-vexpo 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +10 -10
  2. package/dist/index.js +8 -7
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
  5. package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
  6. package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
  7. package/dist/templates/default/.eas/workflows/release.yml +3 -7
  8. package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
  9. package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
  10. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
  11. package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
  12. package/dist/templates/default/.github/workflows/check.yml +20 -12
  13. package/dist/templates/default/.maestro/launch.yaml +19 -10
  14. package/dist/templates/default/AGENTS.md +25 -8
  15. package/dist/templates/default/DESIGN.md +14 -10
  16. package/dist/templates/default/README.md +83 -78
  17. package/dist/templates/default/SETUP.md +159 -152
  18. package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
  19. package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
  20. package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
  21. package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
  22. package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
  23. package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
  24. package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
  25. package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
  26. package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
  27. package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
  28. package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
  29. package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
  30. package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
  31. package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
  32. package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
  33. package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
  34. package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
  35. package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
  36. package/dist/templates/default/_env.example +7 -7
  37. package/dist/templates/default/_gitattributes +1 -1
  38. package/dist/templates/default/_gitignore +17 -2
  39. package/dist/templates/default/_npmrc +7 -0
  40. package/dist/templates/default/_oxlintrc.json +1 -1
  41. package/dist/templates/default/app-store/accessibility.config.json +20 -0
  42. package/dist/templates/default/app-store/privacy.config.json +27 -0
  43. package/dist/templates/default/app.config.ts +105 -33
  44. package/dist/templates/default/app.json +1 -9
  45. package/dist/templates/default/convex/_generated/api.d.ts +12 -0
  46. package/dist/templates/default/convex/admin.ts +0 -13
  47. package/dist/templates/default/convex/appAttest.ts +467 -0
  48. package/dist/templates/default/convex/appAttestStore.ts +141 -0
  49. package/dist/templates/default/convex/apple.ts +53 -0
  50. package/dist/templates/default/convex/auth.ts +6 -45
  51. package/dist/templates/default/convex/constants.ts +2 -7
  52. package/dist/templates/default/convex/crons.ts +12 -5
  53. package/dist/templates/default/convex/email.ts +4 -24
  54. package/dist/templates/default/convex/env.ts +0 -4
  55. package/dist/templates/default/convex/errors.ts +0 -7
  56. package/dist/templates/default/convex/functions.ts +0 -26
  57. package/dist/templates/default/convex/http.ts +3 -5
  58. package/dist/templates/default/convex/log.ts +2 -25
  59. package/dist/templates/default/convex/pushSender.ts +145 -0
  60. package/dist/templates/default/convex/pushTokens.ts +110 -13
  61. package/dist/templates/default/convex/rateLimit.ts +8 -39
  62. package/dist/templates/default/convex/schema.ts +48 -5
  63. package/dist/templates/default/convex/tsconfig.json +1 -0
  64. package/dist/templates/default/convex/users.ts +143 -61
  65. package/dist/templates/default/convex/validators.ts +1 -38
  66. package/dist/templates/default/convex/webhook.ts +1 -31
  67. package/dist/templates/default/convex.json +1 -2
  68. package/dist/templates/default/metro.config.js +9 -1
  69. package/dist/templates/default/package.json +67 -70
  70. package/dist/templates/default/plugins/README.md +5 -1
  71. package/dist/templates/default/scripts/README.md +9 -9
  72. package/dist/templates/default/scripts/_run.mjs +3 -20
  73. package/dist/templates/default/scripts/clean.ts +81 -69
  74. package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
  75. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
  76. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
  77. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
  78. package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
  79. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
  80. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
  81. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
  82. package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
  83. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
  84. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
  85. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
  86. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
  87. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
  88. package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
  89. package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
  90. package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
  91. package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
  92. package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
  93. package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
  94. package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
  95. package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
  96. package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
  97. package/dist/templates/default/src/app/+native-intent.tsx +25 -0
  98. package/dist/templates/default/src/app/+not-found.tsx +43 -0
  99. package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
  100. package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
  101. package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
  102. package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
  103. package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
  104. package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
  105. package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
  106. package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
  107. package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
  108. package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
  109. package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
  110. package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
  111. package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
  112. package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
  113. package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
  114. package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
  115. package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
  116. package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
  117. package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
  118. package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
  119. package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
  120. package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
  121. package/dist/templates/default/src/hooks/use-network.ts +34 -0
  122. package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
  123. package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
  124. package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
  125. package/dist/templates/default/src/lib/appAttest.ts +78 -0
  126. package/dist/templates/default/src/lib/assets.ts +9 -0
  127. package/dist/templates/default/src/lib/deep-link.ts +82 -0
  128. package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
  129. package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
  130. package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
  131. package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
  132. package/dist/templates/default/src/lib/masks.ts +21 -0
  133. package/dist/templates/default/src/lib/native-state.ts +20 -0
  134. package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
  135. package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
  136. package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
  137. package/dist/templates/default/src/lib/text-style.ts +20 -0
  138. package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
  139. package/dist/templates/default/store.config.json +1 -1
  140. package/dist/templates/default/tsconfig.json +3 -1
  141. package/dist/templates/default/vitest.config.ts +8 -1
  142. package/package.json +5 -5
  143. package/dist/templates/default/app/(app)/_layout.tsx +0 -73
  144. package/dist/templates/default/app/(app)/debug.tsx +0 -389
  145. package/dist/templates/default/app/(app)/sessions.tsx +0 -191
  146. package/dist/templates/default/app/(app)/welcome.tsx +0 -140
  147. package/dist/templates/default/app/+native-intent.tsx +0 -14
  148. package/dist/templates/default/app/+not-found.tsx +0 -51
  149. package/dist/templates/default/bun.lock +0 -1860
  150. package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
  151. package/dist/templates/default/components/ui/convex-error.tsx +0 -32
  152. package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
  153. package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
  154. package/dist/templates/default/components/ui/status-text.tsx +0 -49
  155. package/dist/templates/default/components/ui/update-banner.tsx +0 -82
  156. package/dist/templates/default/fingerprint.config.js +0 -9
  157. package/dist/templates/default/hooks/use-debounce.ts +0 -20
  158. package/dist/templates/default/hooks/use-deep-link.ts +0 -43
  159. package/dist/templates/default/hooks/use-network.ts +0 -11
  160. package/dist/templates/default/lib/assets.ts +0 -17
  161. package/dist/templates/default/lib/deep-link.ts +0 -71
  162. package/dist/templates/default/patches/PR-368.patch +0 -91
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
  165. /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
  166. /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
  167. /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
  168. /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
  169. /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
  170. /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
  171. /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
  172. /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
  173. /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
  174. /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
@@ -0,0 +1,85 @@
1
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
2
+ import { Button, Host, Text } from "@expo/ui/swift-ui";
3
+ import {
4
+ accessibilityHint,
5
+ accessibilityLabel,
6
+ buttonStyle,
7
+ contentShape,
8
+ disabled as disabledModifier,
9
+ foregroundStyle,
10
+ frame,
11
+ padding,
12
+ shapes,
13
+ } from "@expo/ui/swift-ui/modifiers";
14
+
15
+ import { Material } from "@/components/ui/material";
16
+ import { useAppUpdates } from "@/hooks/use-updates";
17
+ import { Spacing, FontSize, TouchTarget } from "@/constants/layout";
18
+ import { Radius } from "@/constants/theme";
19
+ import { ZIndex } from "@/constants/ui";
20
+ import { useColors } from "@/hooks/use-theme";
21
+ import { useDynamicFont } from "@/lib/dynamic-font";
22
+
23
+ export function UpdateBanner({ testID }: { testID?: string } = {}) {
24
+ const updates = useAppUpdates();
25
+ const insets = useSafeAreaInsets();
26
+ const colors = useColors();
27
+ const dfont = useDynamicFont();
28
+
29
+ const showProgress = updates.isDownloading;
30
+ const showError = !!updates.downloadError;
31
+ if (!showProgress && !showError) return null;
32
+
33
+ const tint = showError ? (colors.destructive as string) : (colors.primary as string);
34
+ const fg = showError
35
+ ? (colors.destructiveForeground as string)
36
+ : (colors.primaryForeground as string);
37
+ const pct =
38
+ showProgress && updates.downloadProgress != null
39
+ ? ` ${Math.round(updates.downloadProgress * 100)}%`
40
+ : "";
41
+ const label = showError ? "Update failed. Tap to retry." : `Updating${pct}`;
42
+
43
+ return (
44
+ <Material
45
+ accessibilityLiveRegion="polite"
46
+ accessibilityRole="alert"
47
+ variant="chrome"
48
+ tintColor={tint}
49
+ isInteractive={showError}
50
+ style={{
51
+ position: "absolute",
52
+ bottom: insets.bottom + Spacing.xs,
53
+ left: Spacing.md,
54
+ right: Spacing.md,
55
+ zIndex: ZIndex.updateBanner,
56
+ borderRadius: Radius.full,
57
+ overflow: "hidden",
58
+ alignItems: "center",
59
+ }}
60
+ >
61
+ <Host matchContents>
62
+ <Button
63
+ testID="update-banner-retry"
64
+ modifiers={[
65
+ buttonStyle("plain"),
66
+ padding({ vertical: Spacing.sm, horizontal: Spacing.lg }),
67
+ frame({ minHeight: TouchTarget.min }),
68
+ contentShape(shapes.rectangle()),
69
+ disabledModifier(!showError),
70
+ accessibilityLabel(label),
71
+ ...(showError ? [accessibilityHint("Re-attempts the update download")] : []),
72
+ ]}
73
+ onPress={showError ? () => updates.downloadAndApply() : () => {}}
74
+ >
75
+ <Text
76
+ testID={testID}
77
+ modifiers={[dfont({ size: FontSize["3xl"], weight: "bold" }), foregroundStyle(fg)]}
78
+ >
79
+ {label}
80
+ </Text>
81
+ </Button>
82
+ </Host>
83
+ </Material>
84
+ );
85
+ }
@@ -60,12 +60,6 @@ export const Breakpoint = {
60
60
  export const TAB_BAR_HEIGHT = 80;
61
61
  export const TAB_BAR_CLEARANCE = TAB_BAR_HEIGHT + Spacing.lg;
62
62
 
63
- // Single source of truth for prominent action buttons across the auth flow,
64
- // onboarding, error states, and OTP. Keeps Sign In, Sign Up, Send Reset
65
- // Code, Reset Password, Verify, Try Again, and Sign in with Apple visually
66
- // identical: same height, same capsule corner radius, same Geist label
67
- // size and weight. Color comes from the shadcn palette (`primary` /
68
- // `primaryForeground`), never hardcoded.
69
63
  export const Button = {
70
64
  height: 50,
71
65
  cornerRadius: 25,
@@ -1,18 +1,16 @@
1
+ // SDK 56 bans application-code imports from @react-navigation/* in favor of
2
+ // the expo-router re-exports. Theme types come from expo-router/react-navigation,
3
+ // DefaultTheme from expo-router itself. Don't add @react-navigation/native back.
1
4
  import { DefaultTheme as RNDefaultTheme } from "expo-router";
2
5
  import type { Theme as RNTheme } from "expo-router/react-navigation";
3
6
  import { DynamicColorIOS } from "react-native";
4
7
 
5
- import { FontFamily } from "@/constants/layout";
6
-
7
8
  // `DynamicColorIOS` returns an `OpaqueColorValue`. React Native's StyleSheet
8
9
  // processor resolves it natively, but several @expo/ui props and our own
9
10
  // `as string` call-sites expect a string. The runtime payload behaves like
10
11
  // any other ColorValue for RN, so we cast at the boundary instead of
11
12
  // littering every call-site with `as unknown as string`.
12
13
 
13
- // Shadcn `b1VlJDbW` preset (luma + neutral + Geist + Hugeicons + radius default).
14
- // Source: shadcn-ui/ui apps/v4/registry/themes.ts, "neutral" entry.
15
- // OKLCH values converted to sRGB hex via the standard Björn Ottosson matrix.
16
14
  // Each token carries a light + dark variant plus a high-contrast pair for the
17
15
  // iOS Increase Contrast accessibility setting (HIG: "If you define a custom
18
16
  // color, make sure to supply light and dark variants, and an increased
@@ -26,40 +24,48 @@ type Tone = {
26
24
 
27
25
  const tone = (t: Tone): string => DynamicColorIOS(t) as unknown as string;
28
26
 
29
- // Shadcn neutral palette in hex. Indexed by Tailwind v4 neutral step.
30
27
  const NEUTRAL = {
31
28
  white: "#FFFFFF",
32
29
  black: "#000000",
33
- n50: "#FAFAFA", // oklch(0.985)
34
- n100: "#F5F5F5", // oklch(0.97)
35
- n150: "#EBEBEB", // contrast bump for n100
36
- n200: "#E5E5E5", // oklch(0.922)
37
- n300: "#D4D4D4", // oklch(0.87)
38
- n400: "#A1A1A1", // oklch(0.708)
39
- n500: "#737373", // oklch(0.556)
40
- n600: "#525252", // oklch(0.439)
41
- n700: "#404040", // oklch(0.371)
42
- n800: "#262626", // oklch(0.269)
43
- n850: "#1C1C1C", // contrast bump for n900 in dark mode
44
- n900: "#171717", // oklch(0.205)
45
- n950: "#0A0A0A", // oklch(0.145)
30
+ n50: "#FAFAFA",
31
+ n100: "#F5F5F5",
32
+ n150: "#EBEBEB",
33
+ n200: "#E5E5E5",
34
+ n300: "#D4D4D4",
35
+ n400: "#A1A1A1",
36
+ n500: "#737373",
37
+ n600: "#525252",
38
+ n700: "#404040",
39
+ n800: "#262626",
40
+ n850: "#1C1C1C",
41
+ n900: "#171717",
42
+ n950: "#0A0A0A",
46
43
  } as const;
47
44
 
48
45
  const DESTRUCTIVE = {
49
- light: "#E7000B", // oklch(0.577 0.245 27.325)
50
- dark: "#FF6467", // oklch(0.704 0.191 22.216)
51
- hcLight: "#B30009",
46
+ // Light darkened from #E7000B to clear WCAG AA (4.5:1) for sub-17pt text on
47
+ // the muted (n100) capsule fill, not just on the white page background.
48
+ light: "#B30009",
49
+ dark: "#FF6467",
50
+ hcLight: "#990007",
52
51
  hcDark: "#FFA0A2",
53
52
  } as const;
54
53
 
54
+ const WARNING = {
55
+ light: "#B45309",
56
+ dark: "#F59E0B",
57
+ hcLight: "#92400E",
58
+ hcDark: "#FCD34D",
59
+ } as const;
60
+
55
61
  // shadcn dark `border` is `oklch(1 0 0 / 10%)`, `input` is `15%`. These need
56
62
  // the alpha to hover over translucent layers. iOS DynamicColorIOS accepts
57
63
  // 8-digit hex, so we encode RGBA inline.
58
64
  const ALPHA_DARK = {
59
- border: "#FFFFFF1A", // 10%
60
- borderHC: "#FFFFFF40", // ~25% bump for high contrast
61
- input: "#FFFFFF26", // 15%
62
- inputHC: "#FFFFFF59", // ~35% bump
65
+ border: "#FFFFFF1A",
66
+ borderHC: "#FFFFFF40",
67
+ input: "#FFFFFF26",
68
+ inputHC: "#FFFFFF59",
63
69
  } as const;
64
70
 
65
71
  const t = {
@@ -130,9 +136,11 @@ const t = {
130
136
  highContrastDark: NEUTRAL.n850,
131
137
  }),
132
138
  mutedForeground: tone({
133
- light: NEUTRAL.n500,
139
+ // n500 -> n600 so secondary text clears 4.5:1 on the muted card by default,
140
+ // not only under Increase Contrast.
141
+ light: NEUTRAL.n600,
134
142
  dark: NEUTRAL.n400,
135
- highContrastLight: NEUTRAL.n600,
143
+ highContrastLight: NEUTRAL.n700,
136
144
  highContrastDark: NEUTRAL.n300,
137
145
  }),
138
146
  accent: tone({
@@ -217,7 +225,7 @@ const t = {
217
225
  }),
218
226
  sidebarPrimary: tone({
219
227
  light: NEUTRAL.n900,
220
- dark: "#1447E6", // oklch(0.488 0.243 264.376), shadcn dark sidebar accent
228
+ dark: "#1447E6",
221
229
  highContrastLight: NEUTRAL.black,
222
230
  highContrastDark: "#3D6FFA",
223
231
  }),
@@ -252,8 +260,6 @@ const t = {
252
260
  highContrastDark: NEUTRAL.n400,
253
261
  }),
254
262
 
255
- // Translucent fills. Same hue as primary, layered for surface tinting,
256
- // press states, focus glows.
257
263
  primaryFill: tone({
258
264
  light: "rgba(23,23,23,0.06)",
259
265
  dark: "rgba(229,229,229,0.10)",
@@ -292,23 +298,14 @@ const t = {
292
298
  }),
293
299
  } as const;
294
300
 
295
- // Shadcn neutral tokens plus the handful of aliases the app actually
296
- // references. Add an entry here the first time you need it. don't ship
297
- // dead palette rows.
298
301
  export const Colors = {
299
302
  ...t,
300
303
 
301
- // Separator is a fork of the border token so navigation chrome can swap
302
- // it independently without touching shadcn `border`.
303
304
  separator: t.border,
304
305
 
305
- // Tab bar inactive/active states. Mapped to muted-foreground / primary
306
- // so the bar reads as part of the navigation chrome.
307
306
  tabIconDefault: t.mutedForeground,
308
307
  tabIconSelected: t.primary,
309
308
 
310
- // Tertiary label (third-rank caption text) sits between muted and
311
- // background. too faint for body copy, dark enough to read.
312
309
  tertiaryLabel: tone({
313
310
  light: NEUTRAL.n400,
314
311
  dark: NEUTRAL.n500,
@@ -316,7 +313,6 @@ export const Colors = {
316
313
  highContrastDark: NEUTRAL.n400,
317
314
  }),
318
315
 
319
- // Inverse of destructive (white text on destructive fill).
320
316
  destructiveForeground: tone({
321
317
  light: NEUTRAL.white,
322
318
  dark: NEUTRAL.n900,
@@ -324,14 +320,21 @@ export const Colors = {
324
320
  highContrastDark: NEUTRAL.black,
325
321
  }),
326
322
 
327
- // Status green for "available" / "completed" markers (HIG-aligned, not
328
- // shadcn. shadcn doesn't define a success token).
329
323
  success: tone({
330
- light: "#16A34A",
324
+ // Light darkened from #16A34A (~3.3:1 on white) to clear WCAG AA for the
325
+ // sub-17pt success copy in SuccessText and the username-available row.
326
+ light: "#15803D",
331
327
  dark: "#22C55E",
332
- highContrastLight: "#15803D",
328
+ highContrastLight: "#166534",
333
329
  highContrastDark: "#4ADE80",
334
330
  }),
331
+
332
+ warning: tone({
333
+ light: WARNING.light,
334
+ dark: WARNING.dark,
335
+ highContrastLight: WARNING.hcLight,
336
+ highContrastDark: WARNING.hcDark,
337
+ }),
335
338
  } as const;
336
339
 
337
340
  export const HeaderTint = Colors.foreground;
@@ -341,9 +344,7 @@ export type ColorPalette = typeof Colors;
341
344
  // React Navigation `Theme` consumers (NavigationThemeProvider, header tint,
342
345
  // back chevron, screen background) read flat color strings, not
343
346
  // DynamicColorIOS values. We export one theme per appearance and pick at
344
- // the root layout based on `useColorScheme()` so every nav-rendered surface
345
- // (back chevron, badge, header text) tracks the shadcn neutral palette
346
- // instead of iOS systemBlue and the React Navigation defaults.
347
+ // the root layout based on `useColorScheme()`.
347
348
  export const NavigationLight: RNTheme = {
348
349
  dark: false,
349
350
  colors: {
@@ -370,14 +371,6 @@ export const NavigationDark: RNTheme = {
370
371
  fonts: RNDefaultTheme.fonts,
371
372
  };
372
373
 
373
- // shadcn radius scale, --radius = 0.625rem = 10px.
374
- // sm = radius * 0.6 = 6
375
- // md = radius * 0.8 = 8
376
- // lg = radius * 1.0 = 10 (shadcn default)
377
- // xl = radius * 1.4 = 14
378
- // 2xl = radius * 1.8 = 18
379
- // 3xl = radius * 2.2 = 22
380
- // 4xl = radius * 2.6 = 26
381
374
  const RADIUS_BASE = 10;
382
375
  export const Radius = {
383
376
  none: 0,
@@ -391,11 +384,3 @@ export const Radius = {
391
384
  "4xl": Math.round(RADIUS_BASE * 2.6),
392
385
  full: 9999,
393
386
  } as const;
394
-
395
- export const Typography = {
396
- default: { fontSize: 16, lineHeight: 24, fontFamily: FontFamily.regular },
397
- defaultSemiBold: { fontSize: 16, lineHeight: 24, fontFamily: FontFamily.semiBold },
398
- title: { fontSize: 30, lineHeight: 38, fontFamily: FontFamily.bold, letterSpacing: -0.5 },
399
- subtitle: { fontSize: 20, lineHeight: 26, fontFamily: FontFamily.semiBold },
400
- link: { fontSize: 16, lineHeight: 24, fontFamily: FontFamily.regular },
401
- };
@@ -40,6 +40,19 @@ export const Duration = {
40
40
  splash: 1000,
41
41
  } as const;
42
42
 
43
+ // Dynamic Type ceilings for fixed-geometry controls. upstream expo/expo#46007
44
+ // opts the app into native Dynamic Type through the `textStyle` font path, this
45
+ // bounds it where a control can't reflow. `dynamicTypeSize({ max })` from
46
+ // upstream expo/expo#46540 caps growth while still honoring the user's setting.
47
+ export const DynamicType = {
48
+ // Segmented toggle and the "This device" session badge: small controls that
49
+ // tolerate one accessibility step, then stop.
50
+ control: "accessibility1",
51
+ // The OTP field is tightest: six 24pt monospaced glyphs with kerning(8) in a
52
+ // capsule that can't wrap, so it caps below the accessibility sizes.
53
+ otp: "xxLarge",
54
+ } as const;
55
+
43
56
  export const Size = {
44
57
  checkbox: 24,
45
58
  iconContainer: 40,
@@ -68,10 +81,6 @@ export const Keyboard = {
68
81
  verticalOffset: 100,
69
82
  } as const;
70
83
 
71
- export const Accessibility = {
72
- maxFontSizeMultiplier: 2,
73
- } as const;
74
-
75
84
  export const EmptyState = {
76
85
  paddingVertical: 60,
77
86
  } as const;
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebounce<T>(value: T, delay: number): T {
4
+ const [debounced, setDebounced] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const id = setTimeout(() => setDebounced(value), delay);
8
+ return () => clearTimeout(id);
9
+ }, [value, delay]);
10
+
11
+ return debounced;
12
+ }
@@ -0,0 +1,51 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useURL } from "expo-linking";
3
+ import { router, type Href } from "expo-router";
4
+
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { resolveDeepLink } from "@/lib/deep-link";
7
+
8
+ /**
9
+ * Resumes a deep link that arrived BEFORE the user authenticated. Links that
10
+ * arrive while authenticated are navigated by `+native-intent.tsx` (expo-router
11
+ * runs `redirectSystemPath` and drives the navigator itself, query included),
12
+ * so this hook must not re-navigate them or every tap would double-push and
13
+ * stack a phantom copy of the destination. It handles only the deferred case:
14
+ * an incoming URL whose navigation the auth guard blocked, replayed once
15
+ * sign-in completes.
16
+ */
17
+ export function useDeepLinkHandler() {
18
+ // See note in app/_layout.tsx: Better Auth session is the canonical signal.
19
+ // `useConvexAuth` is unreliable due to the bridge's sessionId churn.
20
+ const { data: session } = authClient.useSession();
21
+ const isAuthenticated = !!session?.session;
22
+ const url = useURL();
23
+ const pendingUrl = useRef<string | null>(null);
24
+
25
+ useEffect(() => {
26
+ if (!url) return;
27
+
28
+ if (!isAuthenticated) {
29
+ // native-intent's navigation to a protected route was blocked by the
30
+ // auth guard; remember the link so we can resume it after sign-in.
31
+ pendingUrl.current = url;
32
+ return;
33
+ }
34
+
35
+ // Authenticated. Act only on a link that arrived while unauthenticated;
36
+ // links that arrive while authed are already navigated by native-intent.
37
+ if (pendingUrl.current !== url) return;
38
+ pendingUrl.current = null;
39
+
40
+ let resolved;
41
+ try {
42
+ resolved = resolveDeepLink(url);
43
+ } catch (err) {
44
+ if (__DEV__) console.warn("[DeepLink] parse failed:", err);
45
+ return;
46
+ }
47
+
48
+ if (!resolved.href) return;
49
+ router.push({ pathname: resolved.href, params: resolved.params } as Href);
50
+ }, [isAuthenticated, url]);
51
+ }
@@ -0,0 +1,35 @@
1
+ import { useCallback, useState } from "react";
2
+ import * as LocalAuthentication from "expo-local-authentication";
3
+ import { useMutation } from "convex/react";
4
+
5
+ import { api } from "@/convex/_generated/api";
6
+ import { authClient } from "@/lib/auth-client";
7
+ import { formatError } from "@/components/ui/convex-error";
8
+ import { haptics } from "@/lib/haptics";
9
+
10
+ // Face ID gate, soft-delete mutation, and sign-out for account deletion, shared
11
+ // by the profile and settings screens. The mutation can reject (rate limit,
12
+ // network, server); on failure the caller surfaces `deleteError` instead of
13
+ // leaving the user on an unchanged screen after confirming a destructive action.
14
+ export function useDeleteAccount() {
15
+ const deleteAccountMutation = useMutation(api.users.deleteAccount);
16
+ const [deleteError, setDeleteError] = useState<string | null>(null);
17
+
18
+ const deleteAccount = useCallback(async () => {
19
+ haptics.error();
20
+ const result = await LocalAuthentication.authenticateAsync({
21
+ promptMessage: "Confirm with Face ID",
22
+ });
23
+ if (!result.success) return;
24
+ try {
25
+ setDeleteError(null);
26
+ await deleteAccountMutation();
27
+ await authClient.signOut();
28
+ } catch (err) {
29
+ haptics.error();
30
+ setDeleteError(formatError(err));
31
+ }
32
+ }, [deleteAccountMutation]);
33
+
34
+ return { deleteAccount, deleteError };
35
+ }
@@ -0,0 +1,13 @@
1
+ import { useReducedMotion } from "@/hooks/use-reduced-motion";
2
+
3
+ export function useMotionScreenOptions<A extends string>(
4
+ animation: A,
5
+ animationDuration?: number,
6
+ ): {
7
+ animation: A | "fade";
8
+ animationDuration: number | undefined;
9
+ } {
10
+ const reduceMotion = useReducedMotion();
11
+ if (reduceMotion) return { animation: "fade", animationDuration: 150 };
12
+ return { animation, animationDuration };
13
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useNetworkState } from "expo-network";
3
+
4
+ // expo-network's iOS module uses a temporary `NWPathMonitor` for its initial
5
+ // probe (`getNetworkStateAsync`) with a 5s timeout. On simulator and during
6
+ // cold-start the temp monitor sometimes never fires, so the probe returns
7
+ // `isConnected: false` while the device is actually online. The persistent
8
+ // listener corrects it on the next change event, but until then the banner
9
+ // would flash "You're offline" on a working network. Gate the banner on a
10
+ // short settle window so transient probe failures don't surface.
11
+ const OFFLINE_SETTLE_MS = 3000;
12
+
13
+ export function useNetwork() {
14
+ const { isConnected, isInternetReachable } = useNetworkState();
15
+ // On iOS expo-network sets `isInternetReachable === isConnected`; we keep
16
+ // both checks for cross-platform parity but they collapse to one signal.
17
+ const probablyOffline = isConnected === false || isInternetReachable === false;
18
+ const [settledOffline, setSettledOffline] = useState(false);
19
+
20
+ useEffect(() => {
21
+ if (!probablyOffline) {
22
+ setSettledOffline(false);
23
+ return;
24
+ }
25
+ const id = setTimeout(() => setSettledOffline(true), OFFLINE_SETTLE_MS);
26
+ return () => clearTimeout(id);
27
+ }, [probablyOffline]);
28
+
29
+ return {
30
+ isConnected,
31
+ isInternetReachable,
32
+ isOffline: settledOffline,
33
+ };
34
+ }
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
2
2
  import * as Notifications from "expo-notifications";
3
3
  import { useConvexAuth } from "convex/react";
4
4
  import { useMutation } from "convex/react";
5
- import { router } from "expo-router";
5
+ import { router, type Href } from "expo-router";
6
6
 
7
7
  import { api } from "@/convex/_generated/api";
8
- import { isValidDeepLink } from "@/lib/deep-link";
8
+ import { resolveDeepLink } from "@/lib/deep-link";
9
9
  import {
10
10
  getExpoPushToken,
11
11
  requestPermission,
@@ -18,20 +18,16 @@ interface UseNotificationsOptions {
18
18
  onNotificationsDropped?: () => void;
19
19
  }
20
20
 
21
- /**
22
- * If a notification's payload includes a `url` string, route to it after
23
- * deep-link validation. Add custom action handling (categories, button taps,
24
- * inline replies) here when your app needs it.
25
- */
26
21
  function handleNotificationResponse(response: Notifications.NotificationResponse) {
27
22
  const url = response.notification.request.content.data?.url;
28
23
  if (typeof url !== "string") return;
29
24
 
30
- if (isValidDeepLink(url)) {
31
- router.push(url as Parameters<typeof router.push>[0]);
32
- } else if (__DEV__) {
33
- console.warn("[Notification] Blocked navigation to:", url);
25
+ const { href, params } = resolveDeepLink(url);
26
+ if (!href) {
27
+ if (__DEV__) console.warn("[Notification] Blocked navigation to:", url);
28
+ return;
34
29
  }
30
+ router.push({ pathname: href, params } as Href);
35
31
  }
36
32
 
37
33
  export function useNotifications(options?: UseNotificationsOptions) {
@@ -40,7 +36,6 @@ export function useNotifications(options?: UseNotificationsOptions) {
40
36
  const registered = useRef(false);
41
37
  const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
42
38
 
43
- // Register push token when authenticated
44
39
  useEffect(() => {
45
40
  if (!isAuthenticated) {
46
41
  registered.current = false;
@@ -51,19 +46,37 @@ export function useNotifications(options?: UseNotificationsOptions) {
51
46
  registered.current = true;
52
47
 
53
48
  (async () => {
54
- const { granted } = await requestPermission();
55
- if (!granted) return;
56
-
57
- const token = await getExpoPushToken();
58
- if (!token) return;
59
-
60
- setExpoPushToken(token);
61
- await upsertToken({ token, deviceType: "ios" });
49
+ try {
50
+ const { granted } = await requestPermission();
51
+ if (!granted) return;
52
+
53
+ const token = await getExpoPushToken();
54
+ if (!token) return;
55
+
56
+ setExpoPushToken(token);
57
+ await upsertToken({ token, deviceType: "ios" });
58
+ } catch (e) {
59
+ // Reset so a transient failure (permission throw, network) retries on
60
+ // the next render instead of silently dropping push registration for
61
+ // the session. The early returns above intentionally keep it true.
62
+ registered.current = false;
63
+ if (__DEV__) console.warn("[Notification] registration failed:", e);
64
+ }
62
65
  })();
63
66
  }, [isAuthenticated, upsertToken]);
64
67
 
65
- // Event listeners
66
68
  useEffect(() => {
69
+ // Cold-start deep link: the launch tap arrives via the last-response
70
+ // getter, not the runtime listener below, so handle it once here.
71
+ // (useLastNotificationResponse would fire for BOTH cold-start and runtime,
72
+ // double-navigating every runtime tap.)
73
+ Notifications.getLastNotificationResponseAsync().then((initial) => {
74
+ if (!initial) return;
75
+ handleNotificationResponse(initial);
76
+ options?.onNotificationResponse?.(initial);
77
+ clearLastNotificationResponse();
78
+ });
79
+
67
80
  const receivedSub = Notifications.addNotificationReceivedListener((notification) => {
68
81
  if (__DEV__) console.log("[Notification] Received:", notification.request.identifier);
69
82
  options?.onNotificationReceived?.(notification);
@@ -83,7 +96,11 @@ export function useNotifications(options?: UseNotificationsOptions) {
83
96
  const tokenSub = Notifications.addPushTokenListener(async (token) => {
84
97
  if (__DEV__) console.log("[Notification] Token rotated:", token.data);
85
98
  if (isAuthenticated && typeof token.data === "string") {
86
- await upsertToken({ token: token.data, deviceType: "ios" });
99
+ try {
100
+ await upsertToken({ token: token.data, deviceType: "ios" });
101
+ } catch (e) {
102
+ if (__DEV__) console.warn("[Notification] token upsert failed:", e);
103
+ }
87
104
  }
88
105
  });
89
106
 
@@ -95,13 +112,5 @@ export function useNotifications(options?: UseNotificationsOptions) {
95
112
  };
96
113
  }, [isAuthenticated, options, upsertToken]);
97
114
 
98
- // Cold-start deep linking
99
- const lastResponse = Notifications.useLastNotificationResponse();
100
- useEffect(() => {
101
- if (!lastResponse) return;
102
- handleNotificationResponse(lastResponse);
103
- clearLastNotificationResponse();
104
- }, [lastResponse]);
105
-
106
115
  return { expoPushToken };
107
116
  }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useState } from "react";
2
+ import { AccessibilityInfo } from "react-native";
3
+
4
+ /**
5
+ * Tracks the iOS `Settings → Accessibility → Display & Text Size → Reduce
6
+ * Transparency` flag. iOS 26 `GlassView` honors the setting natively, but the
7
+ * iOS 16.4-25 `BlurView` fallback in `<Material>` does not, so the surface
8
+ * stays translucent for users who explicitly asked for solid backgrounds.
9
+ *
10
+ * Consults `AccessibilityInfo.isReduceTransparencyEnabled()` on mount and
11
+ * keeps the value live via the `reduceTransparencyChanged` event so a runtime
12
+ * toggle in Settings re-renders consumers without a relaunch.
13
+ */
14
+ export function useReduceTransparency(): boolean {
15
+ const [on, setOn] = useState(false);
16
+
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ AccessibilityInfo.isReduceTransparencyEnabled().then((value) => {
20
+ if (!cancelled) setOn(value);
21
+ });
22
+ const sub = AccessibilityInfo.addEventListener("reduceTransparencyChanged", setOn);
23
+ return () => {
24
+ cancelled = true;
25
+ sub.remove();
26
+ };
27
+ }, []);
28
+
29
+ return on;
30
+ }
@@ -42,11 +42,6 @@ export function useColors(): ColorPalette {
42
42
  return Colors;
43
43
  }
44
44
 
45
- // Theme-aware asset selector. Pass the light variant first, dark second.
46
- // Returns whichever matches the active appearance (which honors the in-app
47
- // override from `setTheme` in addition to the system setting).
48
- //
49
- // const icon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
50
45
  export function useThemedAsset<L, D>(light: L, dark: D): L | D {
51
46
  const scheme = useColorScheme();
52
47
  return scheme === "dark" ? dark : light;