Skip to content

Add the Expo app shell with sign-in and the five tabs - #123

Merged
Programcode merged 11 commits into
feat/native-iosfrom
native-ios/03a-mobile-shell
Oct 9, 2026
Merged

Programcode merged 11 commits into
feat/native-iosfrom
native-ios/03a-mobile-shell

Conversation

@Programcode

Copy link
Copy Markdown
Owner

First piece of the native iOS app from plans/2026-10-08-native-ios-app.md: the Expo app workspace at apps/mobile, the five-tab shell, sign-in, and the screenshot harness that stands in for a simulator on the VPS. It targets the long-lived feat/native-ios branch, which reaches main only when Adam approves a milestone.

What the app does so far:

  • Signs in with an email code or a demo code, through the same Supabase calls as the website's AuthProvider. Google sign-in waits for the first development build, because Expo Go cannot receive an OAuth redirect, and Sign in with Apple comes after that.
  • Shows the five tabs from the mocks (Debate, Prepare, Practice, Library, You) with native tabs on iOS and a web fallback for the preview. Debate shows the signed-out and no-debate states; the other tabs say what is still web-only. You shows the account and signs out.
  • Keeps the session in an encrypted store (an AES key in SecureStore, the ciphertext in AsyncStorage), refreshes it only while the app is in the foreground, and survives a cold start without a network. That last case was a real bug while building: supabase-js reports no session for about 30 seconds offline, so the app now starts from the stored session.
  • Talks to staging by default. Production is opt-in through EXPO_PUBLIC_SUPABASE_ENV=production and never a fallback. The project URLs and anon keys have one copy, src/lib/supabase/projects.ts.
  • Uses the mocks' colours, Inter, the pale-blue icon tiles and the blue-to-teal gradient for the one main action, with a dark variant from the website's palette. New strings live under mobile.* in the three catalogs.

Every native module is in the App Store build of Expo Go, so Adam can run this milestone on his iPhone from the VPS dev server over the tailnet with no EAS build. docs/development.md has the runbook; the Metro manifest was checked to hand out the tailnet hostname.

Repo changes outside the app:

  • apps/* joins the npm workspaces. The lockfile diff is additions plus dev-flag changes; no pre-existing package changed version. React 18 stays on the web and React 19 in the app, and the app's web export bundles only 19.
  • vercel.json gets an installCommand that installs the root and packages/core only, so website deploys skip the React Native tree. Proven in a clean clone with npm 10.9.9 (Vercel's Node 22) and 12.0.2: 853 packages, no expo or react-native, build passes.
  • ESLint gets a block for the app with React Native globals, react-hooks, and an error on window, document and web storage outside *.web.ts(x) files and scripts/. npm run typecheck runs the app's tsc; knip runs the app as a workspace.
  • Docs: AGENTS.md and README system maps, the mobile boundary in docs/architecture.md, the runbook in docs/development.md, and the Vercel install note in docs/operations.md.

Checks, run locally on Node 22.23.2 because GitHub Actions starts no jobs on this account (billing):

  • npm run verify exit 0 at the final head: 273 Vitest tests, 30 collaboration tests, 333 Deno Edge Function tests, the build and boundary check, the service-worker test, 30 Chromium smoke e2e tests.
  • Clean clones: default npm ci with scripts enabled, then lint, typecheck, build and the app's web export, all pass at each of the four commits; the Vercel install command variant builds the website.
  • Screenshots of the web export, 65 files under .verify/mobile-shots/: sign-in, its error and demo states, and the signed-in tabs, at iPhone and iPad sizes, light and dark, English and Hungarian, plus the offline cold start. Signed-in shots used a staging demo code; the address on You is masked. A selection is attached below.

Not proven here: anything on a real iPhone (native tabs, large titles, the encrypted store, App Transport Security against a plain-HTTP tailnet name), email-code sign-in end to end, and the sign-out button. Those are the milestone checks on Adam's phone.

🤖 Generated with Claude Code

Programcode and others added 4 commits October 9, 2026 05:29
apps/mobile is @debatelab/mobile: Expo SDK 57, React Native 0.86, React 19
and Expo Router, as a second npm workspace next to packages/core. It has
the DebateLab identity (com.adam.debatelab, scheme debatelab), the colours
from the mocks and the website's dark palette, Inter, and the five tabs
from the mocks with native tabs on iOS and a drawn tab bar for the web
preview. Prepare, Practice, Library and You show a placeholder until their
PRs land. Strings come from the website's catalogs; app-only strings are
under mobile.* in all three languages.

The lockfile adds the app's tree. No existing package changes version: one
entry (sax) moves to the root and 32 lose their dev-only flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The app has its own Supabase client on the shared projects.ts. It
defaults to staging and only reaches production when a build sets
EXPO_PUBLIC_SUPABASE_ENV=production. The session is stored with Supabase's
LargeSecureStore pattern: AES-256-CTR ciphertext in AsyncStorage, a fresh
key per write in the Keychain. Refresh pauses while the app is in the
background.

Sign-in follows the website's AuthProvider contract: signInWithOtp then a
six-digit verifyOtp, or demo-code-login's magic-link token hash with a
per-install id. Google sign-in waits for the first development build,
because Expo Go cannot receive OAuth redirects.

A cold start without a network keeps the user signed in. supabase-js keeps
the stored session when the refresh fails offline but reports
INITIAL_SESSION as null after about 30 seconds of retries, so the app
starts from the stored session and refreshes once the network is back.

scripts/screenshots.ts serves the web export and captures sign-in and the
signed-in tabs at iPhone and iPad sizes, light and dark, English and
Hungarian. With a staging demo code it also checks the offline cold start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…install

eslint gives apps/mobile its own block: no browser globals, no
react-refresh, react-hooks on, require() allowed for PNGs only, and
window, document, localStorage, sessionStorage and location rejected
outside .web.ts(x) files. Generated Expo folders are ignored. The root
typecheck runs the app's own tsc (TypeScript 6, React 19 types), and knip
knows the app's Expo Router entry points. knip ignores expo-updates, which
its Expo plugin expects until app.json disables updates; EAS Update is not
set up yet.

Vercel now installs only the root package and @debatelab/core, so the
website deploy does not install React Native. Checked in a clean clone
with npm 10.9.9 and 12.0.2: 853 packages, no react-native, root React
18.3.1, and npm run build passes.

No allowScripts entry is needed: no package the app adds has an install
script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The app's AGENTS.md and README replace the Expo template text with the
DebateLab rules: Expo Go modules only, staging by default, strings and
project keys shared with the website. The root AGENTS.md, README and
architecture guide describe the apps/mobile boundary. The development guide
covers the screenshot harness and loading the app in Expo Go on an iPhone
from the VPS: Metro over the tailnet via tailnet-preview run with
REACT_NATIVE_PACKAGER_HOSTNAME, a manifest check, and two fallbacks if
App Transport Security rejects plain HTTP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 05:47
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 9, 2026 6:37am UTC
debatelab-staging Ready Ready Preview Oct 9, 2026 6:37am UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository YAML (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Free
  • Run ID: b99760e9-b3a3-4a90-93a3-2b46292bf149

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

Screenshots of the web export at the final head (the native app is close to this but has native tabs, large titles and sheets):

iPhone, light, English: sign-in

iPhone, dark, Hungarian: demo code

iPhone, light, English: Debate tab, signed in

iPhone, dark, Hungarian: You tab (address masked)

iPad, light, English: sign-in

Offline cold start keeps the session

Programcode and others added 7 commits October 9, 2026 06:03
getRandomBytes falls back to Math.random in development builds without
nativeCallSyncHook, which includes Expo Go's bridgeless runtime, so the
session's AES key was not cryptographically random there. getRandomBytesAsync
always calls the native generator.

Overlapping setItem calls could leave the key from one write and the
ciphertext from the other; decrypting that pair returns garbage rather than
the session. Reads, writes and removes now run one at a time per key, and a
failed operation does not block the ones queued after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
supabase-js loads the session before it signs out. Offline with an expired
access token the refresh fails and it returns that error before removing
the stored session, so the app threw and stayed signed in. Sign-out now
tries the global scope, falls back to the local scope, then removes the
stored session and clears the React session whatever the server said, as
the website does. A server failure is only logged.

The auth-change counter moves to a ref so sign-out can invalidate a stored
session read that is still in flight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The screenshot script asserted the tabs right after they rendered from
storage and reloaded before supabase-js's first auth event, which arrives
after about 30 seconds of offline retries. It passed even without the
INITIAL_SESSION fallback it was meant to cover.

The auth context now reports when that first event has been handled, the
root view carries testID auth-settled once it has, and the script waits for
it before checking that the tabs are still shown and before reloading.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The sign-in screen showed the server's English error text under a Spanish
or Hungarian interface. Known failures now map to translated keys: each
demo-code-login error, Supabase Auth's expired code and rate limit codes,
and network failures. Anything else shows the existing generic message for
that step and logs the raw text. The screen keeps the key in state and
translates it at render, so the message follows the language.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AES-CTR with the wrong key returns garbage rather than throwing, so the
catch only fires when that garbage is not valid UTF-8. Either way supabase-js
cannot parse it and treats the user as signed out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Overlapping writes could leave the Keychain key from one write and the
ciphertext from another, which reads as signed out and is invisible when
using the app. The test holds a ciphertext write open to force that
interleaving, checks that a read waits for a pending write, and checks that
a failed write does not block the next. Vitest and knip now pick up tests
under apps/mobile/src.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
signOut no longer throws, so the error state and its message could never
show. The settings.signOutFailed key stays for the website.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Review record, round 1 at b87826b (Codex, gpt-6.1-sol, high effort, detached snapshot). Five findings, all confirmed against the code and fixed on the new head:

  • High: the session key could come from Math.random. expo-crypto's synchronous getRandomBytes falls back to Math.random in development builds without the bridge sync hook, which is how Expo Go runs. Fixed in 44fc160: the key comes from getRandomBytesAsync, which has no fallback.
  • Medium: overlapping session writes could pair a key with another write's ciphertext. Fixed in 44fc160: reads, writes and removes run one at a time per key through a promise queue, and a failed write does not block the next. Test: apps/mobile/src/lib/device-storage.test.ts (c5c791b), three cases; two of them fail against the old adapter, and the old code returned garbage rather than null.
  • Medium: signing out offline left the session on the device. supabase-js returns the failed refresh before removing storage. Fixed in cc13479: sign-out tries global, then local, then always removes the stored session and clears the React state. Proven in the app with an expired token offline: sign-in shown after 67 ms, nothing left in storage.
  • Medium: the harness's offline check asserted before the first auth event. Fixed in 48ca94b: the root view exposes auth-settled once the first onAuthStateChange event is handled, and the check waits for it. Mutation proof: with the INITIAL_SESSION fallback removed the check fails ("Offline cold start signed the user out"); restored, it passes.
  • Low: server error strings showed in English under a Hungarian or Spanish interface. Fixed in 6b0403c: sign-in-errors.ts maps each demo-code-login error and the common auth codes to mobile.signIn.errors.* keys in all three catalogs, with a translated generic fallback and the raw message logged. Checked live against staging in Hungarian and Spanish. The website has the same gap and is left as is.

Also on the new head: e0ce1dc drops the now-unreachable sign-out error state on the You tab.

Accepted as is: a token refresh already in flight when the user signs out could, in theory, write the session back. That race is in supabase-js itself and the website shares it. Offline, sign-out waits behind supabase-js's first-load retries (about 30 seconds) before it completes.

@Programcode

Copy link
Copy Markdown
Owner Author

Review record, round 2 at e0ce1dc (Codex, gpt-6.1-sol, high effort, detached snapshot, with the round-1 findings and dispositions in the brief): no findings. It re-read the five fixes, the regression test and the surrounding auth and storage code, confirmed the old adapter fails both race cases of the new test, probed queue cleanup, sign-out, stale reads and the error mapping, and checked that the effective website and packages/core ESLint configuration is unchanged, the docs check passes, and the website production build with its boundary checker passes.

Both Vercel projects built e0ce1dc with the new install command. GitHub Actions started no jobs (billing); the checks of record are the local run in the description, repeated at the final head: npm run verify exit 0 with 276 Vitest tests (the three new storage cases included), 30 collaboration tests, 333 Deno tests, the build, the service-worker test and 30 Chromium smoke tests. Copilot was out of quota and CodeRabbit does not review PRs against non-default branches.

@Programcode
Programcode merged commit 28b6870 into feat/native-ios Oct 9, 2026
8 of 16 checks passed
@Programcode
Programcode deleted the native-ios/03a-mobile-shell branch October 9, 2026 06:48

This branch was successfully deployed

2 active deployments
Preview – debatelab-staging — e0ce1dc4 Deployed Oct 9, 2026 by vercel[bot]
Preview – debate-navigator — e0ce1dc4 Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants