- TypeScript 93.5%
- JavaScript 4.5%
- Shell 1.7%
- Nix 0.3%
| .agent-memory | ||
| .codex/skills | ||
| .maestro | ||
| .pi | ||
| __tests__ | ||
| assets | ||
| docs | ||
| scripts | ||
| src | ||
| .dir-locals.el | ||
| .envrc | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| AGENTS.md | ||
| app.config.js | ||
| app.json | ||
| babel.config.js | ||
| bun.lock | ||
| eas.json | ||
| eslint.config.js | ||
| flake.lock | ||
| flake.nix | ||
| index.ts | ||
| metro.config.js | ||
| package.json | ||
| README.md | ||
| skills-lock.json | ||
| tsconfig.json | ||
Flashcards
Maestro on the iPhone simulator
Run the iPhone-only Maestro suite against the dedicated test build and InstantDB app:
bun run maestro:test:iphone
The package command loads the dev/test InstantDB identifiers, the test Google iOS identifier, MAESTRO_TEST_EMAIL, and the sensitive MAESTRO_TEST_CODE from the EAS development environment. Keep the test-user credentials in EAS and out of the repository. The runner uses the iPhone 17 Pro with iOS 26.0 by default. It boots or creates that exact simulator, generates a clean native project for the test app variant, builds and installs a bundled Release app, verifies the bundle and embedded variant/backend/provider fingerprints, runs the functional suite in English, runs the localization smoke test in German, and restores the simulator to English with the standard Large Dynamic Type size.
The runner builds de.totap.flashcards.test with APP_VARIANT=test. That one variant selects TEST_INSTANT_APP_ID and TEST_GOOGLE_IOS_CLIENT_ID; there is no independent public backend or variant override. Before the destructive reset, the runner verifies the test app ID against a checked-in SHA-256 allowlist, verifies the built and installed configuration markers, and verifies that Settings shows MAESTRO_TEST_EMAIL. It refuses to run against another InstantDB app or authenticated user.
The test build uses the permanent test code without requesting a new email code, and installing a rebuilt test bundle preserves its authenticated simulator session. This avoids Instant's per-email code-request rate limit while still exercising real token sign-in when the simulator is not already authenticated. Normal app builds retain the standard magic-code request flow.
Reuse the installed build while iterating on flows:
bun run maestro:test
Override MAESTRO_IPHONE_NAME, MAESTRO_IPHONE_DEVICE_TYPE, or MAESTRO_IOS_RUNTIME when required. The functional suite covers creating and reviewing a card, preserving tags while adding another card, editing during review, confirming unsaved-draft dismissal, and deleting during review. A separate German smoke flow verifies localized card creation.
Authentication build variants
APP_VARIANT is the only build selector:
| Variant | Bundle ID | EAS environment | Services |
|---|---|---|---|
dev |
de.totap.flashcards.dev |
preview or development |
DEV_INSTANT_APP_ID and DEV_GOOGLE_IOS_CLIENT_ID |
test |
de.totap.flashcards.test |
development |
TEST_INSTANT_APP_ID and TEST_GOOGLE_IOS_CLIENT_ID |
production |
de.totap.flashcards |
production |
Unavailable until FLA-27 provisions isolated production services |
A missing APP_VARIANT defaults to dev for local work. Unknown variants, missing values, swapped values, and production builds fail during Expo config evaluation. Public runtime values come from validated Expo extra; application code does not read an independent backend environment variable.
Both flashcards-dev and flashcards-test use the Instant auth-client names apple-ios and google-ios. The Apple Services ID is de.totap.flashcards.auth; this native Expo flow has no web domains, return URLs, private key, or Google client secret.
Architecture
The application uses top-level packages with an enforced dependency direction:
src/
├── app/ # Expo Router entry points and cross-package composition
├── auth/ # Sign-in and profile workflows
├── cards/ # Reusable card domain, persistence, queries, audio, and backup
├── editor/ # New/edit card workflows
├── library/ # Card library workflow
├── review-prep/ # Review selection and customization
├── review-session/ # Active review workflow
├── settings/ # Settings and backup orchestration
└── shared/ # Domain-neutral infrastructure and reusable UI
Files under src/app define the stable navigation tree required by Expo Router. Route groups such as (tabs) and (modals) organize navigation without adding URL segments, _layout.tsx configures navigators and providers, [id].tsx represents a dynamic parameter, and +api.ts defines a server endpoint. App entry points also own cross-package handoffs such as editor seeds, review-session seeds, and injected auth operations. Screen packages do not import one another or app modules.
Each package keeps only the directories it needs. Routes adapt navigation-independent inputs to screens, screens compose complete workflows, components contain package-owned UI, hooks orchestrate package state, model contains pure types and rules, and lib contains package-specific utilities. Cards additionally owns data, queries, audio, and backup. Shared remains domain-neutral.
The dependency direction is:
app and root i18n composition
→ auth/editor/library/review-prep/review-session/settings
→ cards
→ shared
Cross-package imports use the public cards and shared paths enforced in eslint.config.js. Native screen code cannot import cards persistence implementations, admin adapters, or server modules. Focused server paths are available only to API routes and cards server code. Use bunx expo lint to verify both ESM and CommonJS imports, including parent-relative escapes.
Tests for package-owned behavior are colocated under each package's __tests__ directory. Only multi-package composition tests belong under root __tests__/app; script tests remain under root __tests__/scripts.
For example, editing an existing card flows through:
src/app/(modals)/edit-card/[id].tsx
→ src/editor/routes/edit-card-route.tsx
→ src/editor/screens/edit-card-screen.tsx
→ editor hooks
→ the cards CardStore contract
→ the cards InstantDB implementation
API routes and preview builds
Do not use Expo's automatic build-time server deployment (EXPO_UNSTABLE_DEPLOY_SERVER). The app uses a stable EAS Hosting alias instead:
https://tobio-flashcards--preview.expo.app
Development keeps using the local Expo server/API routes because EXPO_PUBLIC_API_ORIGIN should be unset locally. Preview builds get EXPO_PUBLIC_API_ORIGIN from eas.json/EAS env, and app.config.js uses it as the Expo Router origin.
Deploy API routes for preview
Deploy API routes manually whenever server code or server environment variables change:
bunx expo export -p web --api-only
bunx eas-cli@latest deploy --environment preview --alias preview
A quick TTS route sanity check should return 401 Unauthorized for an invalid token, not Missing INSTANT_APP_ID:
curl -i -X POST \
https://tobio-flashcards--preview.expo.app/api/tts/draft \
-H 'Authorization: Bearer invalid-token' \
-H 'Content-Type: application/json' \
--data '{"html":"<p>Hello</p>","locale":"en-US"}'
Because the native app points at the stable preview alias, API route changes can be redeployed without rebuilding the app. Rebuild only when native/client code or build configuration changes.
Local iPhone preview build
Connect, unlock, and trust the iPhone. Then pull the EAS preview environment into .env.local:
bunx eas-cli@latest env:pull preview
Regenerate the native iOS project with the preview environment:
bunx expo prebuild --clean --platform ios
Finally, build the Release configuration with local Xcode signing and install it directly on the connected iPhone:
EXPO_SKIP_SERVER_EXPORT=1 bunx expo run:ios --device --configuration Release
EXPO_SKIP_SERVER_EXPORT=1 makes app.config.js use static web output so the native Release build does not try to export the API routes. The command prompts for the target device when necessary.