Client

One React codebase is the website and, wrapped by Capacitor, the iOS and Android apps.

Stack

ConcernChoice
UIReact 19, plain CSS on design tokens (night and day themes)
RoutingTanStack Router; every screen is a lazily loaded chunk
Local dataDexie over IndexedDB, with live queries driving the UI
BuildVite; a separate native build uses absolute API and content URLs
Offline (web)Service worker (Workbox) precaches every asset
NativeCapacitor 8: push, haptics, app lifecycle, store links
ErrorsSentry (browser SDK, loaded lazily); Core Web Vitals to first-party analytics

On-device database

erDiagram
  keeps ||--o{ reviews : "card log"
  seen ||--o{ attempts : "per box"
  decks }o--o{ keeps : "lists boxes"
  outbox }o--|| keeps : "queued writes"
  keeps {
    string id PK "ULID"
    string boxId
    int version
    number createdAt
    number removedAt
  }
  reviews {
    string id PK
    string cardId
    string boxId
    int rating
    string source
    number reviewedAt
  }
  seen {
    string boxId PK
    number firstSeenAt
    number lastSeenAt
    int deepestLayer
  }
  attempts {
    string id PK
    string boxId
    bool correct
    int confidence
    number at
  }
  decks {
    string id PK
    string name
    string boxIds "list"
    string topicId
    number updatedAt
    bool deleted
  }
  outbox {
    string id PK "ULID"
    string type
    string payload "JSON"
    number createdAt
  }

Other tables: kv (settings, sync state, the last verified manifest, cached content files), bundles (verified box bundles) and puzzles (first finish of each daily puzzle).

decision Card state is never stored. It is replayed from keeps, attempts and reviews whenever needed (see Learning Engine). Two devices merge by taking the union of their logs: no conflicts to resolve.

The write path

sequenceDiagram
  participant U as Learner
  participant UI as React UI
  participant DB as IndexedDB
  participant S as Sync engine
  U->>UI: taps Keep
  UI->>DB: one transaction: keeps.add + outbox.add (ULID)
  DB-->>UI: live query re-renders Shelf, Review, Maps
  S->>DB: next batch, user-data lane first
  S->>S: push in the background (retry with backoff)

Writes never wait for the network. The outbox has two lanes: user data (keeps, reviews, attempts, settings, decks) always drains before analytics, so a pile of events can never delay someone's progress.

  • Analytics are capped at 5,000 queued per type.
  • An event guard drops any event name that fires more than 10 times a second (a render-loop safety net) and reports how many it dropped.

Startup and content

  1. Fetch the manifest, network first, falling back to the last verified copy.
  2. Verify its Ed25519 signature against the public keys pinned in the build, choosing the key by id, so keys can rotate without an emergency release.
  3. Load the catalog, taxonomy and puzzles, each checked against its SHA-256 and cached by path.
  4. Hide boxes this build cannot play (see Compatibility).
  5. Prune cached files the manifest no longer lists.
  6. Send one app_started health event: cold-start time, content time, and whether content came from the network or the device.

Code loading

The first download is about 133 KB gzipped. Everything else loads on demand:

  • every screen is its own chunk;
  • each of the 50+ interactives loads when a box first shows it;
  • lesson builders load only for old content that names a lesson without carrying it;
  • the app imports plain values from shared packages, never their schema validators, so the validation library stays out of the bundle.

The service worker precaches every chunk, so on-demand loading still works offline on the web; on phones the files are local.

Updates

stateDiagram-v2
  direction LR
  [*] --> Current
  Current --> Banner: build < latest
  Banner --> Current: update / later (3 days)
  Current --> Blocked: build < min
  Blocked --> Current: updated
  note right of Blocked: "Time for an update"<br/>signed min in release info
PlatformHow a new version arrives
WebThe service worker checks hourly. A version that installs before the first tap or scroll applies at once (guarded against reload loops); otherwise "A fresh version is ready" offers a reload. A signed web.min forces a reload for copies left open on a broken version.
iOS / AndroidThe signed release info carries the latest and minimum build: below latest, a dismissible banner; below min, a blocking screen. After updating, "What's new" appears once.

Screens

Feed · Box · Review · Shelf (decks and kept boxes) · Deck · Shared deck · Play (daily puzzles, labs) · Challenge · Search · Explore · World · Topic · Map (topic and world) · Me (account, reminders, interests, privacy) · Onboarding.

${sch.nav()}