@ramonclaudio/create-vexpo 0.1.3 → 0.1.5

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 (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -15
  3. package/dist/index.js +44 -14
  4. package/dist/templates/default/.eas/workflows/e2e-tests.yml +17 -1
  5. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +3 -3
  6. package/dist/templates/default/.maestro/auth.yaml +229 -0
  7. package/dist/templates/default/.maestro/launch.yaml +5 -5
  8. package/dist/templates/default/.maestro/tour.yaml +294 -0
  9. package/dist/templates/default/.maestro/zz-delete-restore.yaml +174 -0
  10. package/dist/templates/default/AGENTS.md +3 -2
  11. package/dist/templates/default/DESIGN.md +41 -41
  12. package/dist/templates/default/README.md +46 -40
  13. package/dist/templates/default/SETUP.md +34 -19
  14. package/dist/templates/default/_easignore +0 -1
  15. package/dist/templates/default/_env.example +15 -10
  16. package/dist/templates/default/app.config.ts +5 -5
  17. package/dist/templates/default/convex/pushTokens.ts +1 -26
  18. package/dist/templates/default/convex/rateLimit.ts +1 -21
  19. package/dist/templates/default/convex/users.ts +1 -49
  20. package/dist/templates/default/convex/validators.ts +0 -10
  21. package/dist/templates/default/package.json +1 -1
  22. package/dist/templates/default/scripts/README.md +24 -8
  23. package/dist/templates/default/scripts/clean.ts +3 -3
  24. package/dist/templates/default/scripts/gen-update-cert.mjs +3 -1
  25. package/dist/templates/default/src/app/(app)/_layout.tsx +15 -1
  26. package/dist/templates/default/src/app/(app)/auth/forgot-password.tsx +3 -0
  27. package/dist/templates/default/src/app/(app)/auth/reset-password.tsx +3 -0
  28. package/dist/templates/default/src/app/(app)/auth/sign-up.tsx +3 -1
  29. package/dist/templates/default/src/app/(app)/privacy.tsx +3 -2
  30. package/dist/templates/default/src/app/(app)/restore-account.tsx +2 -2
  31. package/dist/templates/default/src/app/(app)/sessions.tsx +15 -5
  32. package/dist/templates/default/src/components/ui/convex-error.tsx +0 -7
  33. package/dist/templates/default/src/constants/ui.ts +0 -11
  34. package/dist/templates/default/src/lib/dev-menu.ts +11 -2
  35. package/dist/templates/default/src/lib/preferences.ts +9 -0
  36. package/package.json +3 -2
@@ -0,0 +1,294 @@
1
+ # Signed-in app tour: walk the authenticated surface without mutating auth.
2
+ # Search -> Settings -> Preferences (appearance + haptics) -> Privacy
3
+ # (analytics toggle) -> Profile (edit + save bio) -> Sessions, then screenshot.
4
+ #
5
+ # Requires an existing signed-in session: this flow launches WITHOUT clearState
6
+ # and asserts the home tab. It does NOT sign in. Run `auth.yaml` first to seed a
7
+ # session. On EAS folder runs Maestro executes flows alphabetically, and
8
+ # `app-tour` sorts BEFORE `auth`, so this file is named `tour.yaml` ('t' > 'a')
9
+ # to guarantee it runs AFTER auth.yaml. As a safety net the flow fails fast with
10
+ # a clear message if it lands on the sign-in screen instead of the tabs.
11
+ #
12
+ # @expo/ui SwiftUI driving notes (same as auth.yaml/launch.yaml):
13
+ # - Inner SwiftUI Text testIDs do NOT surface to Maestro; only Host roots and
14
+ # real controls (TextField, Button, Toggle, Picker) do. Assert screens by
15
+ # their Host `id`, assert static copy by `text`.
16
+ # - Capsule list rows hold a Spacer in the middle, so tap them by label TEXT,
17
+ # not id. ProminentButtons and full-content Buttons hold content in the
18
+ # center, so tap those by `id`.
19
+ # - Segmented Picker segments put their label in the middle: tap by label TEXT
20
+ # (`tapOn: "Dark"`), not by the inner Text testID.
21
+ # - Toggles are real controls: tap by `id`.
22
+ # - The keyboard covers lower fields. Forms use
23
+ # scrollDismissesKeyboard("interactively"), so a downward swipe dismisses it
24
+ # (hideKeyboard is flaky on iOS, don't use it).
25
+ # - waitForAnimationToEnd between navigations; assert-then-tap for tab bars;
26
+ # extendedWaitUntil with generous timeouts after anything that hits Convex.
27
+ #
28
+ # Env vars:
29
+ # MAESTRO_APP_ID bundle id (EAS injects from EXPO_PUBLIC_APP_BUNDLE_ID)
30
+ #
31
+ # Run locally (needs a JDK on PATH, and a signed-in simulator session):
32
+ # MAESTRO_APP_ID=$(grep '^EXPO_PUBLIC_APP_BUNDLE_ID=' .env.local | cut -d= -f2) \
33
+ # maestro test .maestro/tour.yaml
34
+ # Or run the whole suite in order (auth seeds the session for the tour):
35
+ # MAESTRO_APP_ID=... maestro test .maestro/
36
+ # Run on EAS: via `.eas/workflows/e2e-tests.yml` (workflow_dispatch only).
37
+ appId: ${MAESTRO_APP_ID}
38
+ ---
39
+ # Resume on whatever was last open (no clearState). Give the JS bundle and the
40
+ # auth guard time to settle.
41
+ - launchApp
42
+ - waitForAnimationToEnd:
43
+ timeout: 15000
44
+
45
+ # Dev-client builds may show the one-time developer-menu intro sheet after a
46
+ # relaunch. Drag it down to dismiss. Release builds skip this block.
47
+ - runFlow:
48
+ when:
49
+ visible: "This is the developer menu.*"
50
+ commands:
51
+ - swipe:
52
+ start: 50%, 55%
53
+ end: 50%, 95%
54
+ - waitForAnimationToEnd:
55
+ timeout: 5000
56
+
57
+ # The intro sheet can appear late while Metro is still bundling, so check a
58
+ # second time after the bundle settles.
59
+ - waitForAnimationToEnd:
60
+ timeout: 10000
61
+ - runFlow:
62
+ when:
63
+ visible: "This is the developer menu.*"
64
+ commands:
65
+ - swipe:
66
+ start: 50%, 55%
67
+ end: 50%, 95%
68
+ - waitForAnimationToEnd:
69
+ timeout: 5000
70
+
71
+ # Safety net: if the session is missing we land on sign-in. Fail loudly so the
72
+ # failure points at "run auth.yaml first", not at a mystery missing element.
73
+ - assertNotVisible:
74
+ id: "sign-in-screen"
75
+
76
+ # launchApp may resume on a nested screen. Get back to the tabs root via the
77
+ # Home tab, then assert the home Host id. Native tab taps can be flaky, so
78
+ # assert-then-tap.
79
+ - assertVisible: "Home"
80
+ - tapOn: "Home"
81
+ - waitForAnimationToEnd:
82
+ timeout: 5000
83
+ - extendedWaitUntil:
84
+ visible:
85
+ id: "home-screen"
86
+ timeout: 20000
87
+
88
+ # --- 1. Search tab -------------------------------------------------------
89
+ # Search is a native large-title Stack.SearchBar above a Host list. Tap the tab,
90
+ # assert the screen root, then drive the search field by its placeholder.
91
+ - assertVisible: "Search"
92
+ - tapOn: "Search"
93
+ - waitForAnimationToEnd:
94
+ timeout: 5000
95
+ - extendedWaitUntil:
96
+ visible:
97
+ id: "search-screen"
98
+ timeout: 15000
99
+
100
+ # Tap the native search field (matched by its placeholder), type a query, then
101
+ # swipe-dismiss the keyboard. The list filters live; assert the screen survives.
102
+ - tapOn: "Search screens"
103
+ - inputText: "privacy"
104
+ - swipe:
105
+ start: 50%, 35%
106
+ end: 50%, 85%
107
+ - waitForAnimationToEnd:
108
+ timeout: 3000
109
+ - assertVisible:
110
+ id: "search-screen"
111
+ # A result for "privacy" should rank. The result row carries a stable id.
112
+ - assertVisible:
113
+ id: "search-result-privacy"
114
+ # Active search replaces the tab bar, close it ("Close" a11y label) so the
115
+ # tabs come back before navigating on.
116
+ - tapOn: "Close"
117
+ - waitForAnimationToEnd:
118
+ timeout: 3000
119
+
120
+ # --- 2. Settings tab -----------------------------------------------------
121
+ - assertVisible: "Settings"
122
+ - tapOn: "Settings"
123
+ - waitForAnimationToEnd:
124
+ timeout: 5000
125
+ - extendedWaitUntil:
126
+ visible:
127
+ id: "settings-screen"
128
+ timeout: 15000
129
+
130
+ # --- 3. Preferences ------------------------------------------------------
131
+ # Open via the settings row. Capsule row with a Spacer, so tap by label text.
132
+ - assertVisible: "Preferences"
133
+ - tapOn: "Preferences"
134
+ - waitForAnimationToEnd:
135
+ timeout: 5000
136
+ - extendedWaitUntil:
137
+ visible:
138
+ id: "preferences-screen"
139
+ timeout: 15000
140
+ - assertVisible: "APPEARANCE"
141
+
142
+ # Appearance is a segmented Picker. Segment labels live in the middle of each
143
+ # segment, so tap by text: System -> Dark -> back to System.
144
+ - tapOn: "Dark"
145
+ - waitForAnimationToEnd:
146
+ timeout: 3000
147
+ - tapOn: "System"
148
+ - waitForAnimationToEnd:
149
+ timeout: 3000
150
+
151
+ # Haptics is a real Toggle in a capsule row, so tap by id. Toggle off, toggle
152
+ # back on. The screen redraws under it but the Host id stays stable.
153
+ - assertVisible: "HAPTICS"
154
+ - tapOn:
155
+ id: "preferences-haptics"
156
+ - waitForAnimationToEnd:
157
+ timeout: 2000
158
+ - tapOn:
159
+ id: "preferences-haptics"
160
+ - waitForAnimationToEnd:
161
+ timeout: 2000
162
+
163
+ # Back to Settings.
164
+ - swipe:
165
+ start: 2%, 50%
166
+ end: 90%, 50%
167
+ - waitForAnimationToEnd:
168
+ timeout: 5000
169
+ - extendedWaitUntil:
170
+ visible:
171
+ id: "settings-screen"
172
+ timeout: 15000
173
+
174
+ # --- 4. Privacy ----------------------------------------------------------
175
+ # Open via the settings row (capsule, tap by text).
176
+ - assertVisible: "Privacy"
177
+ - tapOn: "Privacy"
178
+ - waitForAnimationToEnd:
179
+ timeout: 5000
180
+ - extendedWaitUntil:
181
+ visible:
182
+ id: "privacy-screen"
183
+ timeout: 15000
184
+
185
+ # Share Analytics is a real Toggle in a capsule row, so tap by id. This is a
186
+ # persisted preference (useShareAnalytics). Flip it off, assert the screen
187
+ # holds, flip it back on so the tour leaves no state behind.
188
+ - assertVisible: "Share Analytics"
189
+ - tapOn:
190
+ id: "privacy-share-analytics"
191
+ - waitForAnimationToEnd:
192
+ timeout: 2000
193
+ - assertVisible:
194
+ id: "privacy-screen"
195
+ - tapOn:
196
+ id: "privacy-share-analytics"
197
+ - waitForAnimationToEnd:
198
+ timeout: 2000
199
+
200
+ # Back to Settings.
201
+ - swipe:
202
+ start: 2%, 50%
203
+ end: 90%, 50%
204
+ - waitForAnimationToEnd:
205
+ timeout: 5000
206
+ - extendedWaitUntil:
207
+ visible:
208
+ id: "settings-screen"
209
+ timeout: 15000
210
+
211
+ # --- 5. Profile ----------------------------------------------------------
212
+ # Open via the settings profile row. Full-content Button, so tap by id.
213
+ - tapOn:
214
+ id: "settings-profile"
215
+ - waitForAnimationToEnd:
216
+ timeout: 5000
217
+ - extendedWaitUntil:
218
+ visible:
219
+ id: "profile-screen"
220
+ timeout: 15000
221
+
222
+ # Edit the Bio field. It is a plain TextField (no newPassword autofill, no eye
223
+ # toggle needed). Tap to focus, append text so `hasChanges` flips true, then
224
+ # swipe-dismiss the keyboard. The avatar button exists but the image picker
225
+ # opens a system sheet Maestro can't drive, so it is left untouched.
226
+ - tapOn:
227
+ id: "profile-bio"
228
+ - inputText: " e2e tour"
229
+ - swipe:
230
+ start: 50%, 35%
231
+ end: 50%, 85%
232
+ - waitForAnimationToEnd:
233
+ timeout: 3000
234
+
235
+ # Save. The inline Save button only renders once `hasChanges` is true and holds
236
+ # its label in the center, so tap by id. (A toolbar Save button also exists, but
237
+ # the inline ProminentButton is the reliable target.)
238
+ - scrollUntilVisible:
239
+ element:
240
+ id: "profile-save"
241
+ direction: DOWN
242
+ - tapOn:
243
+ id: "profile-save"
244
+
245
+ # updateProfile hits Convex. SuccessText is an inner Text (no id surface), so
246
+ # prove the save landed structurally: the inline Save button only renders while
247
+ # hasChanges is true, so it disappears after a successful save.
248
+ - extendedWaitUntil:
249
+ notVisible:
250
+ id: "profile-save"
251
+ timeout: 20000
252
+ - assertVisible: ".*e2e tour.*"
253
+ - assertVisible: "Saved"
254
+
255
+ # Back to Settings.
256
+ - swipe:
257
+ start: 2%, 50%
258
+ end: 90%, 50%
259
+ - waitForAnimationToEnd:
260
+ timeout: 5000
261
+ - extendedWaitUntil:
262
+ visible:
263
+ id: "settings-screen"
264
+ timeout: 15000
265
+
266
+ # --- 6. Sessions ---------------------------------------------------------
267
+ # Open via the settings row (capsule, tap by text).
268
+ - assertVisible: "Sessions"
269
+ - tapOn: "Sessions"
270
+ - waitForAnimationToEnd:
271
+ timeout: 5000
272
+
273
+ # listSessions hits Convex; the list renders after the network round-trip.
274
+ - extendedWaitUntil:
275
+ visible:
276
+ id: "sessions-screen"
277
+ timeout: 20000
278
+ # The signed-in device row carries a "This device" badge (text surfaces).
279
+ - extendedWaitUntil:
280
+ visible: "This device"
281
+ timeout: 20000
282
+
283
+ # Back to Settings.
284
+ - swipe:
285
+ start: 2%, 50%
286
+ end: 90%, 50%
287
+ - waitForAnimationToEnd:
288
+ timeout: 5000
289
+ - extendedWaitUntil:
290
+ visible:
291
+ id: "settings-screen"
292
+ timeout: 15000
293
+
294
+ - takeScreenshot: app-tour-final
@@ -0,0 +1,174 @@
1
+ # Account soft-delete -> restore journey: delete the account from Settings, get
2
+ # kicked to sign-in, sign back in within the 30-day grace window, land on the
3
+ # restore screen, restore, and end up authed on home again.
4
+ #
5
+ # This deployment soft-deletes: `users.deleteAccount` stamps `deletedAt` instead
6
+ # of hard-deleting, then signs out (see src/hooks/use-delete-account.ts). On the
7
+ # next sign-in `getMe` returns the tombstoned user, so the `isAccountDeleted`
8
+ # gate in (app)/_layout.tsx swaps the whole authed tree for `restore-account`.
9
+ # Tapping Restore clears the tombstone and `router.replace("/")` drops us back on
10
+ # the tabs root. Because this flow ends with the account restored and signed in,
11
+ # it leaves the shared test account exactly as auth.yaml left it. The `zz-`
12
+ # prefix makes it sort LAST in alphabetical folder runs so nothing downstream
13
+ # inherits a half-deleted account.
14
+ #
15
+ # Face ID note: `deleteAccount` gates on LocalAuthentication.authenticateAsync
16
+ # before the mutation. The simulator must have a matched/enrolled biometric
17
+ # (Features > Face ID > Enrolled, then Matching Face after the prompt) or the
18
+ # delete silently no-ops (`if (!result.success) return`). On EAS the device
19
+ # profile handles this; locally, trigger "Matching Face" if the run stalls at
20
+ # the delete step.
21
+ #
22
+ # Same @expo/ui SwiftUI conventions as auth.yaml: `testID` maps to the native
23
+ # accessibilityIdentifier and surfaces to Maestro as `resource-id`, so id-based
24
+ # asserts/taps resolve. Driving notes carry over:
25
+ # - Inner SwiftUI Text testIDs do NOT surface (Host roots and real controls
26
+ # do), so assert screens by Host id and static copy by its text.
27
+ # - Capsule rows hold their label left with a Spacer, so tap them by label
28
+ # text. ProminentButton centers its content, so tap it by id.
29
+ # - The delete confirm is a native Alert action; tap it by its exact label
30
+ # "Delete Account" (capital A), distinct from the row's "Delete account".
31
+ # - Dismiss the keyboard with a downward swipe, never hideKeyboard.
32
+ # - SecureField + strong-password autofill swallows typed input, so reveal via
33
+ # the `-visibility` eye toggle (which auto-focuses) before typing.
34
+ # - waitForAnimationToEnd between navigations; extendedWaitUntil after any
35
+ # live Convex network call (sign-in, delete, restore).
36
+ #
37
+ # Env vars (MUST match the account auth.yaml created and signed into):
38
+ # MAESTRO_APP_ID bundle id (EAS injects from EXPO_PUBLIC_APP_BUNDLE_ID)
39
+ # MAESTRO_TEST_EMAIL the same email auth.yaml signed in with
40
+ # MAESTRO_TEST_PASSWORD the same password (at least 10 chars)
41
+ #
42
+ # Requires a signed-in session: this runs AFTER auth.yaml/tour.yaml leave the
43
+ # app authed, so it launches WITHOUT clearState. Runs LAST in folder order.
44
+ #
45
+ # Run locally (needs a JDK on PATH; run auth.yaml first to seed the session):
46
+ # MAESTRO_APP_ID=$(grep '^EXPO_PUBLIC_APP_BUNDLE_ID=' .env.local | cut -d= -f2) \
47
+ # MAESTRO_TEST_EMAIL="e2e+...@example.com" \
48
+ # MAESTRO_TEST_PASSWORD="maestro-test-pw" \
49
+ # maestro test .maestro/zz-delete-restore.yaml
50
+ # Run on EAS: via `.eas/workflows/e2e-tests.yml` (folder run, sorts last).
51
+ appId: ${MAESTRO_APP_ID}
52
+ ---
53
+ # Resume the signed-in session left by auth.yaml. No clearState: this flow
54
+ # depends on the live session and the seeded account.
55
+ - launchApp
56
+ - waitForAnimationToEnd:
57
+ timeout: 15000
58
+
59
+ # Dev-client builds may show the one-time developer-menu intro sheet after a
60
+ # relaunch. Drag it down to dismiss. Release builds skip this block.
61
+ - runFlow:
62
+ when:
63
+ visible: "This is the developer menu.*"
64
+ commands:
65
+ - swipe:
66
+ start: 50%, 55%
67
+ end: 50%, 95%
68
+ - waitForAnimationToEnd:
69
+ timeout: 5000
70
+
71
+ # The intro sheet can appear late while Metro is still bundling, so check a
72
+ # second time after the bundle settles.
73
+ - waitForAnimationToEnd:
74
+ timeout: 10000
75
+ - runFlow:
76
+ when:
77
+ visible: "This is the developer menu.*"
78
+ commands:
79
+ - swipe:
80
+ start: 50%, 55%
81
+ end: 50%, 95%
82
+ - waitForAnimationToEnd:
83
+ timeout: 5000
84
+
85
+ # Authenticated entry: the tabs root mounts on home.
86
+ - extendedWaitUntil:
87
+ visible:
88
+ id: "home-screen"
89
+ timeout: 20000
90
+
91
+ # Into Settings. Native tab taps can be flaky, so assert-then-tap.
92
+ - assertVisible: "Settings"
93
+ - tapOn: "Settings"
94
+ - waitForAnimationToEnd:
95
+ timeout: 5000
96
+ - extendedWaitUntil:
97
+ visible:
98
+ id: "settings-screen"
99
+ timeout: 15000
100
+
101
+ # Delete the account. The row is a destructive capsule (label left, Spacer
102
+ # center), so tap by text.
103
+ - scrollUntilVisible:
104
+ element:
105
+ id: "settings-delete-account"
106
+ direction: DOWN
107
+ - tapOn: "Delete account"
108
+ - waitForAnimationToEnd:
109
+ timeout: 3000
110
+
111
+ # Confirm in the native Alert. The action label is "Delete Account" (capital A),
112
+ # distinct from the row's "Delete account". This triggers the Face ID gate (see
113
+ # header) then the soft-delete mutation and sign-out.
114
+ - assertVisible: "Delete Account"
115
+ - tapOn: "Delete Account"
116
+
117
+ # Soft-delete signs the user out, so the unauthenticated guard mounts the auth
118
+ # group and lands on sign-in. Generous timeout: live network call + Face ID.
119
+ - extendedWaitUntil:
120
+ visible:
121
+ id: "sign-in-screen"
122
+ timeout: 30000
123
+
124
+ # Sign back in WITHIN the grace window with the same credentials. Method toggle
125
+ # defaults to Email.
126
+ - tapOn:
127
+ id: "sign-in-email"
128
+ - inputText: ${MAESTRO_TEST_EMAIL}
129
+ - swipe:
130
+ start: 50%, 35%
131
+ end: 50%, 85%
132
+ # Strong-password autofill dodge: reveal via the eye toggle, which auto-focuses.
133
+ - tapOn:
134
+ id: "sign-in-email-password-visibility"
135
+ - waitForAnimationToEnd:
136
+ timeout: 3000
137
+ - tapOn:
138
+ id: "sign-in-email-password"
139
+ - inputText: ${MAESTRO_TEST_PASSWORD}
140
+ - swipe:
141
+ start: 50%, 35%
142
+ end: 50%, 85%
143
+ - scrollUntilVisible:
144
+ element:
145
+ id: "sign-in-submit"
146
+ direction: DOWN
147
+ - tapOn:
148
+ id: "sign-in-submit"
149
+
150
+ # Soft-deleted within the grace window: `getMe` returns the tombstoned user, so
151
+ # the isAccountDeleted gate routes to restore-account instead of the tabs.
152
+ # Generous timeout: live network call.
153
+ - extendedWaitUntil:
154
+ visible:
155
+ id: "restore-account-screen"
156
+ timeout: 30000
157
+
158
+ # Assert the grace-window copy by a stable text fragment. The full line reads
159
+ # "Your account is set to be permanently deleted on <date>. Restore now to keep
160
+ # your account and all of its data." Match the date-independent phrase.
161
+ - assertVisible: ".*permanently deleted on.*"
162
+
163
+ # Restore. The button is a ProminentButton (content centered), so tap by id.
164
+ - tapOn:
165
+ id: "restore-account-restore"
166
+
167
+ # Restore clears the tombstone and router.replace("/") drops us back on the tabs
168
+ # root. Generous timeout: live network call.
169
+ - extendedWaitUntil:
170
+ visible:
171
+ id: "home-screen"
172
+ timeout: 30000
173
+
174
+ - takeScreenshot: delete-restore-final
@@ -76,8 +76,9 @@ rebrand` fills them in. App Review will reject builds with placeholder
76
76
  - **Pre-approved commands:** `.claude/settings.json` allows read-only
77
77
  `git`/`expo`/`eas`/`convex`/`vexpo` calls + the project's `npm run`
78
78
  scripts (`typecheck`, `lint`, `test`, `format`, `dev`, `fp`) without
79
- per-step permission prompts. Destructive ops (`git push`, `git reset`,
80
- `npm install`, `expo deploy`) still ask.
79
+ per-step permission prompts. The file carries only an allowlist, no
80
+ denylist, so anything not on it (`git push`, `git reset`, `npm install`,
81
+ `expo deploy`, and the rest) prompts by default.
81
82
  - **EAS Convex bootstrap:** `eas integrations:convex:connect` is the
82
83
  upstream SDK 56 path for provisioning a Convex backend, writing
83
84
  `CONVEX_DEPLOY_KEY` + `EXPO_PUBLIC_CONVEX_URL`, and registering the env
@@ -91,7 +91,7 @@ Calm, monochrome, native. The vexpo system pairs a shadcn neutral palette (prese
91
91
 
92
92
  The brand commits to Geist Variable as the single typeface for both UI and marketing assets. Color carries no brand meaning. `destructive` is the only chromatic token, reserved for irreversible actions and validation errors. Brand expression flows through type weight, spacing, rounding, and the iOS material system.
93
93
 
94
- Ground truth lives in `constants/theme.ts` (color palette as `DynamicColorIOS`), `constants/layout.ts` (spacing, font sizes, line heights, fonts), and `constants/ui.ts` (opacity, materials, shadows, durations, sizes). The tokens here are the public contract for agents and contributors. The constants files are the implementation.
94
+ Ground truth lives in `constants/theme.ts` (color palette as `DynamicColorIOS`), `constants/layout.ts` (spacing, font sizes, line heights, fonts), `constants/ui.ts` (opacity, shadows, durations, sizes), and `components/ui/material.tsx` (the `BLUR_INTENSITY`, `BLUR_TINT`, and `GLASS_STYLE` maps that drive each material variant). The tokens here are the public contract for agents and contributors. The constants files are the implementation.
95
95
 
96
96
  ## Colors
97
97
 
@@ -214,7 +214,7 @@ Depth is signaled by surface tint and the iOS material system, not by drop shado
214
214
 
215
215
  Materials apply a vibrancy effect that lets background content show through with controlled blur and saturation. Apple's HIG calls this the "navigation layer" and explicitly reserves it for chrome. Don't put materials on form sections or content cards.
216
216
 
217
- The blur intensity values are calibrated. Don't lower them. The 35% tint overlay on the `BlurView` fallback path is also calibrated, the smallest tint that still reads as the requested color while preserving the blur.
217
+ The blur intensity values (`ultraThin` 30, `thin` 50, `regular` 70, `thick` 90, `chrome` 100) live in the `BLUR_INTENSITY` map in `components/ui/material.tsx`. They are calibrated. Don't lower them. The 35% tint overlay on the `BlurView` fallback path is also calibrated, the smallest tint that still reads as the requested color while preserving the blur.
218
218
 
219
219
  ## Shapes / Radius
220
220
 
@@ -254,53 +254,53 @@ The system uses `@expo/ui/swift-ui` primitives exclusively for native rendering.
254
254
 
255
255
  ### Native primitives (use directly)
256
256
 
257
- | Primitive | What it is | Where it lands |
258
- | :-------------------------- | :---------------------------------------------------------------------------- | :------------------------------------- |
259
- | `Host` | SwiftUI host view, top-level wrapper for any screen using `@expo/ui/swift-ui` | Every screen root |
260
- | `VStack` / `HStack` | SwiftUI stacks with spacing + alignment | Layout primitives |
261
- | `Form` / `Section` | Native iOS Form with grouped sections | Settings, profile editor, preferences |
262
- | `Picker` | Segmented or wheel picker (use `pickerStyle("segmented")` for inline) | Theme mode, motion preference |
263
- | `Toggle` | Native iOS toggle | Boolean preferences |
264
- | `Button` | Native button with role variants (default, cancel, destructive) | All actions |
265
- | `TextField` / `SecureField` | Native text input | Forms |
266
- | `Text` | SwiftUI text with modifier composition | All typography |
267
- | `Image` | SF Symbol renderer (`systemName="..."`) | All inline icons |
268
- | `ConfirmationDialog` | Native iOS action sheet | Sign-out, delete account, photo picker |
269
- | `BottomSheet` | Native sheet with detents | Password change, secondary forms |
270
- | `Spacer` | Flexible space | Layout |
271
- | `ProgressView` | Spinner or determinate progress | Loading states |
272
- | `ContentUnavailableView` | Empty state with SF Symbol + title + description | Empty home, no results |
257
+ | Primitive | What it is | Where it lands |
258
+ | :-------------------------- | :---------------------------------------------------------------------------- | :----------------------------------------------------------------------------- |
259
+ | `Host` | SwiftUI host view, top-level wrapper for any screen using `@expo/ui/swift-ui` | Every screen root |
260
+ | `VStack` / `HStack` | SwiftUI stacks with spacing + alignment | Layout primitives |
261
+ | `Form` / `Section` | Native iOS Form with grouped sections | Available but unused. Screens hand-build capsule rows with `VStack` instead |
262
+ | `Picker` | Segmented or wheel picker (use `pickerStyle("segmented")` for inline) | Theme mode, motion preference |
263
+ | `Toggle` | Native iOS toggle | Boolean preferences |
264
+ | `Button` | Native button with role variants (default, cancel, destructive) | All actions |
265
+ | `TextField` / `SecureField` | Native text input | Forms |
266
+ | `Text` | SwiftUI text with modifier composition | All typography |
267
+ | `Image` | SF Symbol renderer (`systemName="..."`) | All inline icons |
268
+ | `ConfirmationDialog` | Native iOS action sheet | Sign-out, delete account, photo picker |
269
+ | `BottomSheet` | Native sheet with detents | Available but unused. Modal screens use `presentation: "modal"` routes instead |
270
+ | `Spacer` | Flexible space | Layout |
271
+ | `ProgressView` | Spinner or determinate progress | Loading states |
272
+ | `ContentUnavailableView` | Empty state with SF Symbol + title + description | Empty home, no results |
273
273
 
274
274
  Text inputs bind `text` to a `useNativeState("")` and mask synchronously: a `"worklet"` `onTextChange` rewrites the field on the same frame the keystroke lands (digits-only OTP, lowercase usernames), so the raw character never paints. Reusable masks live in `lib/masks.ts`.
275
275
 
276
276
  ### Custom composition (in `components/ui/`)
277
277
 
278
- | Component | Purpose | Notes |
279
- | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------- |
280
- | `Material` | Translucent surface with iOS 26+ Liquid Glass / iOS 16.4-25 BlurView fallback | Reserve for navigation chrome only |
281
- | `OfflineBanner` | Top-of-screen notification banner using `Material` chrome variant | Shows when network unavailable |
282
- | `LoadingScreen` | Brand-icon + spinner, themed by appearance | Suspense fallback |
283
- | `ErrorBoundary` | Top-level crash boundary with brand recovery UI | Wraps each route segment |
284
- | `ConvexError` | Maps Convex errors to user-readable copy | Used in error displays |
285
- | `SkeletonProfile` / `SkeletonSessions` | Static loading placeholders. No animation, so nothing to suppress under Reduce Motion. Filler bars carry an empty `accessibilityLabel` so VoiceOver skips the placeholder shapes. | Profile loading, sessions loading |
286
- | `StatusText` | `ErrorText` + `SuccessText` with accessibility announcements | Form feedback |
278
+ | Component | Purpose | Notes |
279
+ | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ |
280
+ | `Material` | Translucent surface with iOS 26+ Liquid Glass / iOS 16.4-25 BlurView fallback | Reserve for navigation chrome only |
281
+ | `OfflineBanner` | Top-of-screen notification banner using `Material` chrome variant | Shows when network unavailable |
282
+ | `LoadingScreen` | Brand-icon + spinner, themed by appearance | Suspense fallback |
283
+ | `ErrorBoundary` | Top-level crash boundary with brand recovery UI | Wraps each route segment |
284
+ | `formatError` | Maps a Convex error to user-readable copy (`components/ui/convex-error.tsx`) | Used by `profile/index.tsx` and `use-delete-account.ts` |
285
+ | `SkeletonProfile` / `SkeletonSessions` | Static loading placeholders. No animation, so nothing to suppress under Reduce Motion. Filler bars carry an empty `accessibilityLabel` so VoiceOver skips the placeholder shapes. | Profile loading, sessions loading |
286
+ | `StatusText` | `ErrorText` + `SuccessText` with accessibility announcements | Form feedback |
287
287
 
288
288
  ### Custom hooks (in `hooks/`)
289
289
 
290
- | Hook | Purpose |
291
- | :-------------------------------- | :---------------------------------------------------------------------------------------------- |
292
- | `useThemeMode` / `useColorScheme` | App-level light/dark/system override on top of OS appearance |
293
- | `useColors` | Returns the `Colors` palette (currently constant, kept as a hook for future per-theme variants) |
294
- | `useThemedAsset` | Picks light or dark asset based on active appearance |
295
- | `useDynamicFont` | Multiplies declared font sizes by accessibility fontScale before passing to `@expo/ui` `font()` |
296
- | `useReducedMotion` | Combines OS Reduce Motion + in-app override. Drives animation duration / disable |
297
- | `useNetwork` | Online / offline state for `OfflineBanner` |
298
- | `useNotifications` | Push token registration + foreground handler |
299
- | `useDeepLinkHandler` | Handles `applinks:` URLs from associated domains |
300
- | `useUpdates` | EAS Update check + apply with branded UI |
301
- | `useOnboarding` | First-launch welcome flow gate |
302
- | `useDebounce` | Standard debounce |
303
- | `useNavigationTracking` | Analytics / route logging |
290
+ | Hook | Purpose |
291
+ | :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
292
+ | `useThemeMode` / `useColorScheme` | App-level light/dark/system override on top of OS appearance |
293
+ | `useColors` | Returns the `Colors` palette (currently constant, kept as a hook for future per-theme variants) |
294
+ | `useThemedAsset` | Picks light or dark asset based on active appearance |
295
+ | `useDynamicFont` | Maps the declared font size to a SwiftUI `textStyle` passed to `@expo/ui` `font()`, so iOS Dynamic Type scales it natively |
296
+ | `useReducedMotion` | Combines OS Reduce Motion + in-app override. Drives animation duration / disable |
297
+ | `useNetwork` | Online / offline state for `OfflineBanner` |
298
+ | `useNotifications` | Push token registration + foreground handler |
299
+ | `useDeepLinkHandler` | Resumes a deep link the auth guard deferred until sign-in. Live applinks route through `+native-intent.tsx` |
300
+ | `useAppUpdates` | EAS Update check + apply with branded UI |
301
+ | `useOnboarding` | First-launch welcome flow gate |
302
+ | `useDebounce` | Standard debounce |
303
+ | `useNavigationTracking` | Analytics / route logging |
304
304
 
305
305
  ## Do's and Don'ts
306
306