Skip to content

Latest commit

 

History

History
453 lines (317 loc) · 15.3 KB

File metadata and controls

453 lines (317 loc) · 15.3 KB

AGENTS.md

Guidance for AI agents working with the Zaparoo App codebase.

Commands

Development

  • npm run dev - Start development server with Vite
  • npm run dev:server - Start with dev server mode (requires DEV_SERVER_IP in .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 mode
  • npm run sync - Sync web app with mobile platforms
  • npm run typecheck - TypeScript type checking
  • npm run lint / npm run lint:fix - ESLint checking/fixing
  • npm run format / npm run format:check - Prettier formatting
  • npx cap open ios / npx cap open android - Open native projects

Testing

  • npm run test - Run Vitest tests
  • npm run test:coverage - Run tests with coverage report
  • npm run test:ui - Run tests with Vitest UI

Live Updates

  • npm run live-update - Build and push signed live update (requires live-update-private.pem)

Architecture

Tech Stack

  • 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

Directory Structure

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

Key Files

  • src/lib/store.ts - Main Zustand store for connection/app state
  • src/lib/preferencesStore.ts - Persisted preferences with Capacitor storage
  • src/lib/coreApi.ts - JSON-RPC API client for Zaparoo Core
  • src/lib/logger.ts - Logging utility with Rollbar integration
  • src/lib/nfc.ts - NFC operations with session management
  • src/lib/models.ts - TypeScript interfaces and API method enums
  • src/lib/transport/ConnectionManager.ts - WebSocket connection management
  • src/i18n.ts - i18next configuration

Testing

Full guide: docs/testing.md

Quick Reference

  • Follow Testing Trophy: prioritize integration tests over unit tests
  • Use AAA pattern: Arrange-Act-Assert
  • Use should + verb naming: 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() from test-utils/factories.ts

Critical Anti-Patterns

  1. Never create fake components inside test files - import real ones
  2. Don't mock the component being tested - only mock external deps
  3. Use it.each for repetitive test patterns
  4. Never use hardcoded delays - use waitFor or findBy*
  5. Don't test CSS classes - test accessible behavior

Test Checklist

  • Imports real component from src/
  • Uses accessible queries
  • Tests observable behavior
  • Uses userEvent.setup() for interactions
  • Uses findBy* or waitFor for async

Logging

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


Platform & Capacitor

Full guide: docs/capacitor.md

Platform Detection

import { Capacitor } from "@capacitor/core";

if (Capacitor.isNativePlatform()) {
  /* native only */
}
const platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'

Plugins Used

@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

Feature Availability

// Hooks check availability at startup, cache in preferencesStore
const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable);
const cameraAvailable = usePreferencesStore((state) => state.cameraAvailable);

Hooks: useNfcAvailabilityCheck(), useCameraAvailabilityCheck(), useAccelerometerAvailabilityCheck()

Key Patterns

  • NFC: Use withNfcSession() from src/lib/nfc.ts for auto-cleanup
  • Haptics: Use useHaptics() hook - respects user preference
  • Storage: Use Capacitor Preferences, not localStorage
  • Safe Area: Use safeInsets from useStatusStore

Internationalization (i18n)

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 }); // Pluralization

Adding translations: Only update src/translations/en-US.json

In tests: Translations return keys as-is - test for keys


State Management

Main Store (useStatusStore)

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

Preferences Store (usePreferencesStore)

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

Hydration

const hasHydrated = usePreferencesStore((state) => state._hasHydrated);
if (!hasHydrated) return <LoadingState />;

Store Testing

beforeEach(() => {
  // Reset to known state - stores don't have getInitialState()
  useStatusStore.setState({
    connected: false,
    runQueue: null,
    // ... set all needed state
  });
});

API Communication

JSON-RPC Protocol

{ jsonrpc: "2.0", id: requestId, method: "media.search", params: { query: "mario" } }

CoreAPI Client

import { CoreAPI } from "@/lib/coreApi";

const result = await CoreAPI.getVersion();
const searchResults = await CoreAPI.searchMedia({ query: "mario" });
CoreAPI.reset(); // Call in test cleanup

WebSocket Transport

  • 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

Queue System

  • 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);

Component Patterns

Libraries

  • src/components/ui/ - shadcn/ui components (Radix-based)
  • src/components/wui/ - Custom Zaparoo components

wui Conventions

  • Use memo() and forwardRef()
  • Use classnames for conditional Tailwind
  • Support aria-label, aria-expanded, aria-controls
  • Use intent prop for haptic feedback: "default" | "primary" | "destructive"

Button/Card

<Button label="Submit" variant="fill" size="default" intent="primary" onClick={handle} />
<Card onClick={handle} disabled={isDisabled}>{children}</Card>

Styling

  • Tailwind CSS classes
  • CSS variables: bg-button-pattern, bg-card-pattern
  • Focus: focus-visible:ring-2 focus-visible:ring-white/50

Error Handling

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

Accessibility

Required Patterns

  • Skip Links: <SkipLink />
  • Page Heading Focus: usePageHeadingFocus()
  • Announcements: A11yAnnouncerProvider, useA11yAnnounce()
  • Screen Reader: useScreenReaderEnabled()
  • Text Zoom: textZoomLevel in preferencesStore

Code Style

  • Imports: Use @/ alias for src directory
  • TypeScript: Strict mode, no any without justification
  • Async: Prefer async/await, handle errors explicitly
  • Hooks: Prefix with use, extract to src/hooks/
  • Naming: Components PascalCase, hooks camelCase with use, tests .test.tsx

Pull Requests

  • No test plans: Do not include test plan sections in PR descriptions
  • Summary only: Keep PR descriptions concise with a brief summary of changes

Core Version Gating

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.

Key files

  • src/lib/coreVersion.ts — semver helpers: isDevelopmentVersion, parseVersion, compareVersions, satisfies
  • src/lib/featureGates.tsregistry of gated features (edit this to gate new features)
  • src/hooks/useCoreFeature.ts — React hook returning { available, requiredVersion, marquee }
  • src/components/GatedFeature.tsx — wrapper component
  • src/components/CoreOutdatedNotice.tsx — settings page notice (auto-shown when any gate fails)

How to gate a new feature

  1. Add an entry to FEATURE_GATES in src/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)
  • marqueetrue: show disabled with tooltip; false: hide silently
  • labelKey — i18n key used in the outdated notice list
  1. Add the translation key to src/translations/en-US.json under "features".

  2. Wrap UI in <GatedFeature featureId="screenshot"> or call useCoreFeature("screenshot") directly.

Dev build handling

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.

Store state

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.


Common Gotchas

  1. NFC Cancellation: Check error.message.includes("cancelled") - fragile but necessary
  2. Platform Checks: Always check Capacitor.isNativePlatform() before native features
  3. Store Hydration: Wait for _hasHydrated before rendering persisted-state UI
  4. Translation Keys: In tests, translations return keys - test for keys not strings
  5. WebSocket Reconnection: Transport handles this automatically - don't manually reconnect
  6. Safe Area Insets: Use safeInsets from store for notched device padding
  7. Touch vs Click: wui components handle touch/scroll distinction internally

Live Updates & CI/CD

Full guide: docs/deployment.md

When to Use

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)

Version Bumping (Required for Store Releases)

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

Process

  • Live Update: npm run live-update builds, 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

Secrets (Capawesome Cloud)

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

Pro features gated by RevenueCat. Primary feature: "Launch on scan" (phone as wireless reader).

import { useProAccessCheck } from "@/hooks/useProAccessCheck";
const { hasProAccess, isLoading } = useProAccessCheck();