Repository navigation
Local Development
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 devOpen 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.
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_hereThe 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 requiredRestart npm run dev afterwards — Vite reads .env once at startup, so a
running dev server will not pick up either flag.
npx vercel link
npm run dev:fulldev: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 byapi/functions must use explicit.jsextensions. 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.
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:
-
No
.envat all.VITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEYare undefined, soisAuthConfigured()is false, the sign-in UI never appears, and with no session there is nothing to sync. This is the usual cause. -
.envpresent butVITE_SYNC_ENABLEDunset orfalse. You can sign in, butstart()andmarkDirty()are no-ops.
Why it is not gated on connectivity is explained in Offline-First Sync Model.
| 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 |
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.
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.
Design
Working on it