Guidance for AI agents working with the Zaparoo App codebase.
npm run dev- Start development server with Vitenpm run dev:server- Start with dev server mode (requiresDEV_SERVER_IPin.env)npm run build- TypeScript compile + Vite build + Capacitor sync (production)npm run build:web- Build web version only (no Capacitor sync)npm run build:core- Build for embedded Core modenpm run sync- Sync web app with mobile platformsnpm run typecheck- TypeScript type checkingnpm run lint/npm run lint:fix- ESLint checking/fixingnpm run format/npm run format:check- Prettier formattingnpx cap open ios/npx cap open android- Open native projects
npm run test- Run Vitest testsnpm run test:coverage- Run tests with coverage reportnpm run test:ui- Run tests with Vitest UI
npm run live-update- Build and push signed live update (requireslive-update-private.pem)
- Frontend: React 19 + TypeScript + Vite
- Mobile: Capacitor 8 (cross-platform iOS/Android)
- Routing: TanStack Router with file-based routing
- State: Zustand (
src/lib/store.ts,src/lib/preferencesStore.ts) - Styling: TailwindCSS 4 with custom CSS variables
- Networking: WebSocket + HTTP JSON-RPC with Zaparoo Core
- Testing: Vitest + React Testing Library + MSW
- i18n: i18next (7 languages)
- CI/CD: Capawesome Cloud
src/
components/ # React components
ui/ # shadcn/ui-based components
wui/ # Custom Zaparoo UI components
home/ # Home page components
nfc/ # NFC-related components
hooks/ # Custom React hooks
lib/ # Core utilities, API clients, store, models
transport/ # WebSocket transport layer (ConnectionManager, WebSocketTransport, types)
routes/ # TanStack Router file-based routes
translations/ # i18n JSON files
__tests__/ # Test suites
unit/ # Unit tests
integration/ # Integration tests
validation/ # Configuration validation tests
test-utils/ # Test helpers, factories, MSW handlers
__mocks__/ # Capacitor plugin mocks
src/lib/store.ts- Main Zustand store for connection/app statesrc/lib/preferencesStore.ts- Persisted preferences with Capacitor storagesrc/lib/coreApi.ts- JSON-RPC API client for Zaparoo Coresrc/lib/logger.ts- Logging utility with Rollbar integrationsrc/lib/nfc.ts- NFC operations with session managementsrc/lib/models.ts- TypeScript interfaces and API method enumssrc/lib/transport/ConnectionManager.ts- WebSocket connection managementsrc/i18n.ts- i18next configuration
Full guide: docs/testing.md
- Follow Testing Trophy: prioritize integration tests over unit tests
- Use AAA pattern: Arrange-Act-Assert
- Use
should + verbnaming:it("should show error when email is invalid") - Import from
test-utils:import { render, screen, waitFor } from "../../../test-utils" - Always use
userEvent.setup()for interactions - Use
findBy*for async content,queryBy*to assert absence - Use accessible queries:
getByRole>getByLabelText>getByText>getByTestId - Factory:
mockReaderInfo()fromtest-utils/factories.ts
- Never create fake components inside test files - import real ones
- Don't mock the component being tested - only mock external deps
- Use
it.eachfor repetitive test patterns - Never use hardcoded delays - use
waitFororfindBy* - Don't test CSS classes - test accessible behavior
- Imports real component from
src/ - Uses accessible queries
- Tests observable behavior
- Uses
userEvent.setup()for interactions - Uses
findBy*orwaitForfor async
import { logger } from "@/lib/logger";
logger.log("General info"); // Dev only
logger.debug("Debug details"); // Dev only
logger.warn("Warning message"); // Dev only
logger.error("Error", error, {
// Always + Rollbar in prod
category: "nfc",
action: "write",
severity: "warning",
});Categories: nfc, storage, purchase, api, camera, accelerometer, queue, connection, share, lifecycle, websocket, general
Severities: critical, error, warning, info, debug
Full guide: docs/capacitor.md
import { Capacitor } from "@capacitor/core";
if (Capacitor.isNativePlatform()) {
/* native only */
}
const platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'@capacitor/core, @capacitor/app, @capacitor/browser, @capacitor/clipboard, @capacitor/device, @capacitor/filesystem, @capacitor/haptics, @capacitor/network, @capacitor/preferences, @capacitor/screen-reader, @capacitor/share, @capacitor/status-bar, @capacitor/text-zoom, @capacitor-community/keep-awake, @capacitor-firebase/authentication, @capacitor-mlkit/barcode-scanning, @capawesome-team/capacitor-nfc, @capawesome/capacitor-live-update, @capgo/capacitor-shake, capacitor-plugin-safe-area, capacitor-zeroconf
// Hooks check availability at startup, cache in preferencesStore
const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable);
const cameraAvailable = usePreferencesStore((state) => state.cameraAvailable);Hooks: useNfcAvailabilityCheck(), useCameraAvailabilityCheck(), useAccelerometerAvailabilityCheck()
- NFC: Use
withNfcSession()fromsrc/lib/nfc.tsfor auto-cleanup - Haptics: Use
useHaptics()hook - respects user preference - Storage: Use Capacitor
Preferences, not localStorage - Safe Area: Use
safeInsetsfromuseStatusStore
Languages: en-US (default), en-GB, fr-FR, zh-CN, ko-KR, ja-JP, nl-NL, de-DE
Files: src/translations/*.json
import { useTranslation } from "react-i18next";
const { t } = useTranslation();
t("nav.back"); // Simple key
t("error", { msg: "Failed" }); // Interpolation
t("duration.hours", { count: 5 }); // PluralizationAdding translations: Only update src/translations/en-US.json
In tests: Translations return keys as-is - test for keys
import { useStatusStore } from "@/lib/store";
const connected = useStatusStore((state) => state.connected);
const setConnected = useStatusStore((state) => state.setConnected);Key state: connected, connectionState, connectionError, targetDeviceAddress, lastToken, gamesIndex, playing, runQueue, writeQueue, deviceHistory, safeInsets
import { usePreferencesStore } from "@/lib/preferencesStore";
const launchOnScan = usePreferencesStore((state) => state.launchOnScan);
const setLaunchOnScan = usePreferencesStore((state) => state.setLaunchOnScan);Key settings: restartScan, launchOnScan, shakeEnabled, shakeMode, hapticsEnabled, textZoomLevel, tourCompleted, nfcAvailable, cameraAvailable
const hasHydrated = usePreferencesStore((state) => state._hasHydrated);
if (!hasHydrated) return <LoadingState />;beforeEach(() => {
// Reset to known state - stores don't have getInitialState()
useStatusStore.setState({
connected: false,
runQueue: null,
// ... set all needed state
});
});{ jsonrpc: "2.0", id: requestId, method: "media.search", params: { query: "mario" } }import { CoreAPI } from "@/lib/coreApi";
const result = await CoreAPI.getVersion();
const searchResults = await CoreAPI.searchMedia({ query: "mario" });
CoreAPI.reset(); // Call in test cleanup- Auto-reconnection: 1s initial, 1.5x multiplier, 30s max
- Heartbeat: 15s ping/pong, 10s timeout
- Message queuing: up to 100 while disconnected
- States: disconnected → connecting → connected → reconnecting
runQueue- Game launch commands (useRunQueueProcessor)writeQueue- NFC write operations (useWriteQueueProcessor)
// Set queue item
useStatusStore.getState().setRunQueue({ value: "game.launch", unsafe: false });
// Clear queue
useStatusStore.getState().setRunQueue(null);src/components/ui/- shadcn/ui components (Radix-based)src/components/wui/- Custom Zaparoo components
- Use
memo()andforwardRef() - Use
classnamesfor conditional Tailwind - Support
aria-label,aria-expanded,aria-controls - Use
intentprop for haptic feedback:"default"|"primary"|"destructive"
<Button label="Submit" variant="fill" size="default" intent="primary" onClick={handle} />
<Card onClick={handle} disabled={isDisabled}>{children}</Card>- Tailwind CSS classes
- CSS variables:
bg-button-pattern,bg-card-pattern - Focus:
focus-visible:ring-2 focus-visible:ring-white/50
try {
await riskyOperation();
} catch (error) {
logger.error("Operation failed", error, {
category: "api",
action: "riskyOperation",
});
showRateLimitedErrorToast(t("error", { msg: error.message }));
}- Error boundary:
src/components/ErrorComponent.tsx - Toast rate limiting:
showRateLimitedErrorToast()- 2s cooldown
- Skip Links:
<SkipLink /> - Page Heading Focus:
usePageHeadingFocus() - Announcements:
A11yAnnouncerProvider,useA11yAnnounce() - Screen Reader:
useScreenReaderEnabled() - Text Zoom:
textZoomLevelin preferencesStore
- Imports: Use
@/alias for src directory - TypeScript: Strict mode, no
anywithout justification - Async: Prefer async/await, handle errors explicitly
- Hooks: Prefix with
use, extract tosrc/hooks/ - Naming: Components PascalCase, hooks camelCase with
use, tests.test.tsx
- No test plans: Do not include test plan sections in PR descriptions
- Summary only: Keep PR descriptions concise with a brief summary of changes
The app must gracefully degrade when connected to older Core versions during migration periods. This is handled client-side via semver comparison — no Core changes required.
src/lib/coreVersion.ts— semver helpers:isDevelopmentVersion,parseVersion,compareVersions,satisfiessrc/lib/featureGates.ts— registry of gated features (edit this to gate new features)src/hooks/useCoreFeature.ts— React hook returning{ available, requiredVersion, marquee }src/components/GatedFeature.tsx— wrapper componentsrc/components/CoreOutdatedNotice.tsx— settings page notice (auto-shown when any gate fails)
- Add an entry to
FEATURE_GATESinsrc/lib/featureGates.ts:
export const FEATURE_GATES: Record<string, FeatureGate> = {
screenshot: {
since: "2.6.0",
marquee: true,
labelKey: "features.screenshot",
},
};since— minimum Core version (semver)marquee—true: show disabled with tooltip;false: hide silentlylabelKey— i18n key used in the outdated notice list
-
Add the translation key to
src/translations/en-US.jsonunder"features". -
Wrap UI in
<GatedFeature featureId="screenshot">or calluseCoreFeature("screenshot")directly.
Versions matching DEVELOPMENT, empty string, or containing -dev/-rc/-beta/-alpha are treated as dev builds and pass all gates automatically. This mirrors Core's config.IsDevelopmentVersion semantics.
coreVersion and corePlatform are fetched once per connect in ConnectionProvider.handleConnectionOpen and stored in useStatusStore. They are cleared by resetConnectionState() on device switch. Do not maintain a separate version query in components — read from the store.
- NFC Cancellation: Check
error.message.includes("cancelled")- fragile but necessary - Platform Checks: Always check
Capacitor.isNativePlatform()before native features - Store Hydration: Wait for
_hasHydratedbefore rendering persisted-state UI - Translation Keys: In tests, translations return keys - test for keys not strings
- WebSocket Reconnection: Transport handles this automatically - don't manually reconnect
- Safe Area Insets: Use
safeInsetsfrom store for notched device padding - Touch vs Click: wui components handle touch/scroll distinction internally
Full guide: docs/deployment.md
| Change Type | Method |
|---|---|
| UI/JS fixes, translations, new features with existing plugins | Live Update (npm run live-update) |
| New plugins, native code, new permissions, Capacitor upgrades | Store Release (push git tag) |
Before creating a store release, all three locations must be updated:
| File | Fields | Notes |
|---|---|---|
package.json |
version |
Semantic version (e.g., 1.10.0) |
android/app/build.gradle |
versionCode, versionName |
versionCode must increment every build (integer), versionName matches package.json |
ios/App/App.xcodeproj/project.pbxproj |
MARKETING_VERSION, CURRENT_PROJECT_VERSION |
MARKETING_VERSION matches package.json, CURRENT_PROJECT_VERSION increments per upload of same version |
- Live Update:
npm run live-updatebuilds, signs, and uploads bundle - Store Build: Push git tag (e.g.,
v1.9.2) triggers Capawesome Cloud build - Rollback: App auto-rolls back if crash before
ready()called
NPM_TOKEN, FIREBASE_CREDS, GOOGLE_SERVICES_JSON, GOOGLE_SERVICE_INFO_PLIST, VITE_GOOGLE_STORE_API, VITE_APPLE_STORE_API, VITE_ROLLBAR_ACCESS_TOKEN, LIVE_UPDATE_PRIVATE_KEY
Pro features gated by RevenueCat. Primary feature: "Launch on scan" (phone as wireless reader).
import { useProAccessCheck } from "@/hooks/useProAccessCheck";
const { hasProAccess, isLoading } = useProAccessCheck();