Contributing › Development
Development
Local setup, commands, monorepo layout, conventions and architecture decisions.
Code comments refer to this file (docs/development.md, mostly its
conventions and anti-patterns). For the pull request workflow, see the organisation’s
CONTRIBUTING.md. For an overview of how the pieces fit together, see
Architecture.
Stack
- Framework: Next.js 15 (App Router), React 19, strict TypeScript
- Internal API: Server Actions + route handlers, Zod validation at the boundary
- Database: PostgreSQL 17 + Drizzle ORM (versioned SQL migrations)
- Cache / queue: Redis (Valkey) — search cache, worker job queue, rate limits
- Server state: TanStack Query · Virtualisation: TanStack Virtual
- Styles: Tailwind CSS 4 (design tokens in
@theme) + Radix Primitives - Icons:
lucide-react,stroke-width: 1.75 - Auth: Auth.js v5, Email provider (magic link or code) + Resend, Drizzle adapter
- Tests: Vitest (unit + integration) · Playwright (critical paths)
- Runtime: Docker Compose —
web,worker,postgres,redis
Local setup
Requirements: Node.js 24, pnpm 10 (corepack enable), Docker.
cp .env.example .env # then set AUTH_SECRET (openssl rand -base64 32)docker compose up -d postgres redispnpm installpnpm db:migratepnpm import:bulk # downloads the Scryfall bulk data (a few hundred MB)pnpm dev # http://localhost:3000A single .env at the repository root is read by every tool (Next.js, the
worker, drizzle-kit, Playwright). Without RESEND_API_KEY, sign-in links are
printed in the pnpm dev console. The first account created on an empty
database becomes the administrator (only ADMIN_EMAIL may create it, if set).
pnpm dev downloads the mana symbols from Scryfall into apps/web/public/mana/
the first time (they belong to Wizards of the Coast and are never committed).
Commands
Run from the repository root:
pnpm dev # Next.js dev server (apps/web)pnpm build # production build of apps/webpnpm typecheck # tsc --noEmit in every package — must pass before each commitpnpm lint # ESLint over the whole workspace — must pass before each commitpnpm test # Vitest, package by package (unit + integration)pnpm test:e2e # Playwright (apps/web)pnpm db:generate # new Drizzle migration from packages/db/src/schema.tspnpm db:migrate # apply the migrationspnpm import:bulk # Scryfall bulk import (also run nightly by the worker)pnpm worker # run the worker on the hostdocker compose up -d # postgres + redis + worker (built locally)docker compose --profile full up -d --build # + web, built like in productionIntegration tests start their own throwaway database (Compose profile test,
port 5433) and skip themselves if Docker is not available. Packages run one
after the other: they share that database.
Monorepo layout
.├── apps/│ ├── android/ # @spellcache/android — Capacitor shell for Android│ │ ├── www/ # server selection screen (plain HTML/JS, no build)│ │ └── android/ # Android Studio project (Gradle)│ ├── docs/ # @spellcache/docs — Starlight site that publishes docs/│ ├── web/ # @spellcache/web — Next.js app│ │ ├── app/ # App Router: routes, layouts, Server Actions│ │ ├── components/ # ui/ primitives + cards/, decks/, collection/, …│ │ ├── lib/ # web-only logic (auth, collections, decks, search…)│ │ ├── public/ # icons (copied from design/assets/icon at build), sw.js│ │ ├── scripts/ # copy-assets.mjs, fetch-mana.mjs│ │ └── tests/ # unit/, integration/, e2e/│ └── worker/ # @spellcache/worker — bulk import, prices, valuations│ ├── src/ # run as TypeScript by Node, no build step│ └── tests/├── packages/│ ├── core/ # @spellcache/core — Scryfall client and schemas, image URLs,│ │ # job queue contract; no Next.js dependency│ └── db/ # @spellcache/db — Drizzle schema, migrations, PostgreSQL client,│ # shared test database setup (testing/)├── deploy/ # backup/restore scripts, migrator, container entrypoint├── design/assets/icon/ # app icon sources├── docs/ # documentation sources (Markdown), published by apps/docs├── docker-compose.yml # development└── docker-compose.example.yml # self-hosting (GHCR images)Rules of the layout:
- Code shared by the web app and the worker lives in
packages/; code only the web app uses stays inapps/web/lib/. The worker never imports fromapps/web. packages/*are published as TypeScript sources (no build): Next.js compiles them (transpilePackages), Node runs them directly in the worker. Their internal relative imports carry the.tsextension, and they never use the@/alias (it only exists inapps/web).
Documentation site
The pages live in docs/ as Markdown, readable on GitHub. apps/docs is a
Starlight project that publishes them to
GitHub Pages; it holds no content of its own.
pnpm docs:dev # http://localhost:4321/spellcache/pnpm docs:build # static site in apps/docs/dist/- Every page starts with a front matter holding at least
title(the page heading: no#title in the body) anddescription. - A new page is added to the
sidebarofapps/docs/astro.config.mjs. - Links between pages are relative to the published URL, with a trailing
slash: from
docs/guide/decks.md,../trading/or../../self-hosting/backup/. - Use
.mdxonly for Starlight components (tabs, cards). Asides use the:::note,:::tip,:::cautionand:::dangersyntax. - Configuration is built from
.env.exampleand Changelog includesCHANGELOG.md: edit those files, not the pages. - The
Docsworkflow builds the site on every pull request that touches it and deploys it whenmainchanges.
Android app
apps/android wraps the web app in a native shell with
Capacitor. The APK contains no copy of the app: its
only local page (www/) asks for the address of a spellcache instance, then
opens that instance in the WebView. Everything else is served by the instance,
so the Android app follows the server’s version.
- Navigation: only the chosen instance’s origin stays in the app
(
ServerPlugin.shouldOverrideLoad); links to other domains open in the browser. Capacitor’s bridge is injected into the local page only, never into the instance’s pages. - Back button: walks the WebView history. From the instance’s first page it returns to the server screen (to change server); from there it leaves the app.
- Card scanner:
/tools/scanner(Settings › Tools › Card scanner) is the review screen; itsScan cardsbutton navigates tospellcache://scanner, whichScannerPluginintercepts to openScannerActivity(CameraX + ML Kit text recognition, bundled model, no Google Play services needed). Each stable reading (set code, collector number and language at the bottom left, the top lines as name candidates) is looked up with the WebView’s session cookie (GET /api/scanner/lookup); only that text leaves the phone. On close,MainActivityhands the scanned cards to the page (window.__spellcacheScanned), which keeps them insessionStorageuntil they are added in one write. The page shows the button only when the user agent carriesSpellcacheScanner(an app built with the scanner). The sound of a counted card ispluck_001from Kenney’s Interface Sounds (CC0), inapp/src/main/res/raw/scanner_pluck.ogg. - Sign-in: the emailed link opens in the browser, not in the app. In the app, sign in with the code from the same email.
- Requirements on the server: HTTPS with a certificate Android trusts.
Plain HTTP and self-signed certificates are refused. The debug build is the
only exception: it also accepts
http://localhost(see below).
Testing on a phone
The debug build is a separate app, spellcache dev
(io.github.spellcache.dev), installed next to the release one. It accepts
http://localhost:<port> in addition to HTTPS servers, so it can open the
local dev server through adb reverse:
pnpm dev # terminal 1: the web app on port 3000pnpm android:dev # terminal 2: sync, adb reverse, install, launchIn the app, enter http://localhost:3000. Changes to the web app reload on
the phone through HMR; run pnpm android:dev again only after changing the
shell (www/, Java, capacitor.config.ts). Sign in with the code printed by
pnpm dev (no Resend key in development).
- Connection: USB with USB debugging on, or Wireless debugging paired
with
adb pair <ip>:<port>. Theadb reversemapping is lost when the phone disconnects: runpnpm android:devagain. - DevTools:
chrome://inspecton the computer lists the app’s WebView (console, network, elements), for the server screen and the instance alike. - Native logs:
adb logcat, or Android Studio (pnpm --filter @spellcache/android open) for breakpoints in Java. - Another server: the dev app also opens any HTTPS instance, to reproduce a bug against real data with DevTools.
DEV_PORT=3001 pnpm android:devwhenpnpm devruns on another port.
Build locally (Node, JDK 21, Android SDK; Android Studio installs the last two):
pnpm --filter @spellcache/android sync # copies www/ into the Android projectcd apps/android/android./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apkpnpm --filter @spellcache/android open opens the project in Android Studio
(emulator, debugger). Run sync again after changing www/ or
capacitor.config.ts.
Releases: the Release workflow builds a signed APK when release-please
creates a release and attaches it to the GitHub release, with its SHA-256.
release-please bumps versionName in app/build.gradle; versionCode is
derived from it (1.2.3 → 10203). Signing uses four repository secrets:
| Secret | Value |
|---|---|
ANDROID_KEYSTORE_BASE64 |
the keystore, base64-encoded |
ANDROID_KEYSTORE_PASSWORD |
keystore password |
ANDROID_KEY_ALIAS |
key alias |
ANDROID_KEY_PASSWORD |
key password |
Create the keystore once and keep a backup outside GitHub: an APK signed with another key cannot update existing installs.
keytool -genkeypair -v -keystore spellcache-release.jks -alias spellcache -keyalg RSA -keysize 4096 -validity 10000base64 -w0 spellcache-release.jks # value of ANDROID_KEYSTORE_BASE64Conventions
- Files and folders in
kebab-case; React components inPascalCase; functions and variables incamelCase; SQL columns insnake_case. - Components live in
components/<domain>/, without prefix: card rows and tiles incards/(CompactRow,GridTile…), decks indecks/, shelves incollection/, table tools intools/, the logo inbrand/, primitives inui/. - UI copy is in English. User-facing documentation (README,
docs/) is in English; code comments are in French; identifiers are in English. - Every external input (form, URL param, payload, Scryfall response) is validated by a Zod schema at the boundary, never cast.
- Server Actions for mutations; route handlers only for what must answer raw HTTP (images, auth, health).
- Colours, radii, spacing and font sizes come from the Tailwind tokens defined
in
@theme(bg-surface-1,text-accent…). No hard-coded hex. One token name per role, even when two roles share a value. - Tailwind’s dynamic spacing scale is disabled (
--spacing: initialin@theme). A numeric step is always its declared literal value:py-13is 13px, nevercalc(var(--spacing) * 13)= 52px. An undeclared step emits no class — the gap shows on screen and in review instead of silently producing a wrong value. Add the missing token, never reintroduce a multiplication fallback. Values that are not a grid step get a proper name (--spacing-tab-bar-reserve: 96px, used aspb-tab-bar-reserve), not an integer that happens to fit. - Lint and format: ESLint (
next/core-web-vitals+@typescript-eslint) and Prettier.pnpm typecheck && pnpm lintmust pass before every commit. - Commit messages follow Conventional Commits: they drive the changelog and the version (release-please).
mainis the only long-lived branch. release-please keeps a release pull request open against it; merging that pull request tags the version, publishes the GitHub release, the Docker images and the APK. Dependabot opens weekly dependency updates (npm and GitHub Actions, minor and patch grouped).- Every Drizzle migration is generated (
pnpm db:generate), reviewed, then committed with the feature that introduces it.
Architecture decisions
- No Scryfall API call during a user request — the catalogue is a local mirror fed by the bulk files. Ignoring Scryfall’s 429 responses can lead to a permanent block, and prices only change once a day.
containers+holdingsas the single model for the collection, binders, decks and lists — the four screens share the same list, search and valuation code.- A collection is a shared space, not an account’s property:
collections+collection_members(role owner|editor|viewer).containerspoint to a collection, never to auser_id. - Preferences are stored on the account (
userstable), not in the browser, so mobile and desktop agree. Only the state of a running table tool game is local:lifeGameinlocalStorage, the running playtest and the cards scanned but not yet added insessionStorage(they survive a reload, not a new tab). users.price_sourceis the single source of truth for currency and market (tcgplayer_usd⇒ $,cardmarket_eur⇒ €). Both Settings rows edit that one value; the app has no currency conversion.- Precomputed values in
container_stats— the value banner reads one row, it never aggregates the holdings on load. - Cursor pagination, never
OFFSET: infinite scroll over 1,000 cards must stay constant-time. - Optimistic updates on every quantity, rolled back on failure.
- Rebuildable catalogue / irreplaceable user data: only user tables are backed up; the catalogue is re-imported with one command.
Anti-patterns
- Do NOT call the Scryfall API from the browser or during a user request —
only two server-side exceptions exist (set released today, card missing from
the catalogue), capped at 2 req/s through
/cards/collection. - Do NOT build Scryfall image URLs by hand: they come from the bulk data.
- Do NOT crop or cover the copyright line or the artist name on a card image.
- Do NOT hard-code a hex value, radius or spacing in a component — use the
@themetokens. A value missing from the tokens means a missing token, not an exception. Only two files hold literal values:apps/web/app/globals.css(the@themeblock) andapps/web/lib/binders/gradients.ts(the six gradients of the pip palette). - Do NOT store preferences in
localStorage(exceptlifeGame) — they belong to the account. - Do NOT add
user_idtocontainers: ownership goes throughcollection_members. There is no second authorisation path. - Do NOT add a second currency setting or an exchange rate:
price_sourceis unique. - Do NOT paginate with
OFFSET, do NOT aggregate holdings when loading a value screen. - Do NOT copy a deck’s card list into a dedicated table: a deck is a
container, its cards areholdings. - Do NOT add password authentication or an external OAuth provider.
- Do NOT use variable row heights in a virtualised list.
- Do NOT invent new visual language: features marked
PlannedorSoonin Settings (AI assistant) stay greyed-out rows with no screen, and a new screen is built from existing components and tokens. Discuss any visual novelty in an issue first. - Do NOT cache user data offline: the app is always online; offline use is not a requirement.