Skip to content

Local Development

Shimon Esterkin edited this page Oct 6, 2026 · 2 revisions

Local Development

Core app — no account, no secrets

Prerequisites: Node.js 22 (pinned in .nvmrc, and required by Capacitor 8) and npm. Vercel builds and functions use the project setting, currently Node.js 24.x.

git clone https://github.com/blackhebrewisraeli/deutsch-app.git
cd deutsch-app
npm install --legacy-peer-deps
npm run dev

Open http://localhost:5173.

--legacy-peer-deps is also set in .npmrc, so a bare npm install works too. It is there because eslint-plugin-react declares a peer range that stops at eslint 9 while the project uses eslint 10; the plugin works fine, the constraint is stale.

What works with no secrets at all: placement, Alphabet, Vocab multiple-choice over preset decks and the lexicon, A1 translate tiles, A2 fill-the-blanks, Stats, streak/XP/SRS, quests. That is the whole core loop.

What needs the AI backend: Chat tutor replies, B1 free-typing grading, custom deck generation, and on-demand sentence generation when a bank runs out.

A good no-secrets smoke test: continue without an account → take (or skip past) placement → Vocab → start the "Greetings" preset deck → answer cards → watch XP, level, and Stats update. That exercises the engine end to end.

Accounts, sync, leagues, avatars

Additional prerequisites: Docker and the Supabase CLI.

supabase start
cp .env.example .env
supabase status -o env      # copy SERVICE_ROLE_KEY into .env

.env.example ships a placeholder rather than the real local key:

SUPABASE_SERVICE_ROLE_KEY=your_local_service_role_key_here

The actual local key is fixed and published, and only ever authenticates against 127.0.0.1 — but GitHub secret scanning flags the sb_secret_ prefix on sight, and an alert nobody can action is an alert everyone learns to ignore.

Never put a cloud service-role key here. It bypasses RLS completely. See Security & Role Architecture.

Then enable the client features you want to exercise:

VITE_SYNC_ENABLED=true
VITE_LEAGUES_ENABLED=true
VITE_GOOGLE_AUTH_ENABLED=false   # provider + OAuth client required
VITE_GITHUB_AUTH_ENABLED=false   # provider + GitHub OAuth App required
VITE_APPLE_AUTH_ENABLED=false    # provider + Apple identifiers/keys required

Restart npm run dev afterwards — Vite reads .env once at startup, so a running dev server will not pick up either flag.

Full app with AI endpoints

npx vercel link
npm run dev:full

dev:full runs vercel dev, serving the Vite app and the /api functions together, and injects the Development-scope environment values. Use plain npm run dev for UI and offline-first work.

Gotcha: files under src/ that are imported by api/ functions must use explicit .js extensions. Vite and Vitest resolve extensionless imports happily; native Node ESM on Vercel does not, and the result is a 500 in production that nothing local reproduces.

"Sync looks dead locally" — it is configuration, not a code path

Nothing checks for development, localhost, or navigator.onLine. The only gate is VITE_SYNC_ENABLED === 'true', and it behaves identically on a laptop and in production. Two settings switch it off, and missing both is the default for a fresh clone, because .env is git-ignored and only .env.example is tracked:

  1. No .env at all. VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are undefined, so isAuthConfigured() is false, the sign-in UI never appears, and with no session there is nothing to sync. This is the usual cause.
  2. .env present but VITE_SYNC_ENABLED unset or false. You can sign in, but start() and markDirty() are no-ops.

Why it is not gated on connectivity is explained in Offline-First Sync Model.

Every npm script

Command Purpose
npm run dev Vite dev server (port 5173)
npm run dev:full vercel dev: Vite plus the /api functions
npm run build Production PWA build
npm run build:mobile Production-configured build plus Capacitor sync
npm run cap:sync Sync the current web build into iOS and Android
npm run preview Serve the production build locally
npm test Full Vitest suite
npm run test:watch Vitest in watch mode
npm run test:coverage Coverage report
npm run test:rls Adversarial PostgREST/RLS suite; needs Docker + Supabase
npm run lint ESLint over src/, api/, and supabase/
npm run lint:fix ESLint with --fix
npm run format Prettier write over src/
npm run format:check Prettier check over src/
npm run smoke:learning-path Browser smoke for the anonymous learning loop
npm run smoke:auth-learning-path Browser smoke for auth, sync, profile, and sign-out
npm run screenshots:capture Capture and validate native store screenshots
npm run where Locate a component and its dependents (backs /component)
npm run audit:dead Find unreferenced modules
npm run audit:contrast Rendered-DOM contrast audit via Playwright
npm run audit:layout Layout/overflow probe
npm run bench:perf Performance benchmark
npm run clean Clear build and cache artifacts
npm run check:migrations Compare committed migrations against production
npm run seed:lessons Seed the curated pack into the lessons table
npm run import:lexicon Rebuild the lexicon from source corpora
npm run vendor:fonts Re-vendor Fraunces, Plus Jakarta Sans, JetBrains Mono, and the IPA subset
npm run gen:assets Regenerate web and native brand assets
npm run release:bump Bump native release metadata
npm run ios:upload Upload a signed iOS archive

Things that will bite you

Vitest workers versus swap. Flaky ~33-second timeouts were memory, not time: eight jsdom forks on a 16 GB machine with Docker running pushed the box into swap. Capping maxForks: 4 took the suite from 397s to 28.5s. Check vm.swapusage before raising a timeout.

userEvent + fake timers hang. userEvent.click hangs for 30 seconds under vi.useFakeTimers(), skips the test's own finally { vi.useRealTimers() }, and leaks fake timers into every later test in the file.

isAuthConfigured() differs between your machine and CI. Vitest loads .env, so it is true locally and false in CI. Mock lib/auth explicitly, or each machine tests the opposite branch. To reproduce CI locally, hide .env.

Never bypass the pre-commit hook. .husky/pre-commit runs lint-staged and the full npm test, so commits take roughly 30 seconds. Also avoid git -c core.hooksPath=.husky commit — it silently skips husky 9's real hook path and lands a commit with no test run.

jsdom mangles CSS min() / calc(). min(400px, calc(100vw - 16px)) reads back as garbage, so a CSS-only clamp has no assertable form in jsdom. Compute responsive clamps in JS if you need to test them.

Narrow viewports

Verify at 375px and 320px, and with a populated account — a fresh one hides elements that only exist with real progress (freeze chip, high level, long rank names). Grid tracks are always minmax(0, 1fr), never a bare 1fr: a 1fr track keeps min-width: auto, refuses to shrink below its content, and pushes the page wider than the viewport. That caused mobile overflow in four separate places.