A rapid-prototyping platform for browser games, optimized for live iPad-over-LAN iteration. Static-only deploy to GitHub Pages, all state in IndexedDB, and a single bun run check that gates every change.
Read CLAUDE.md for the architectural spec — the why behind every constraint here. This README is the operator's manual: how to install, develop, test, build, deploy, and review.
- The Four Pillars
- Stack at a glance
- Prerequisites
- First-time setup
- Local development
- The CLI gate
- Local testing
- Build & preview
- Deploy to GitHub Pages
- Architecture (sequence diagram)
- Scaffolding new apps
- Repository layout
- Code review checklist
- Known gaps
- Troubleshooting
Every change in this repo is judged against these four rules. Anything that violates one is reverted, not patched.
- Storybook-first — a component does not exist until a sibling
*.stories.tsxdoes. Build the smallest piece in isolation; compose into routes. - Zod-first types — every prop, atom value, IDB record, env var, and route param starts as
z.object({...}). The TS type isz.infer<typeof Schema>. Hand-written types that mirror a schema are forbidden. - IDB-first state — IndexedDB is the source of truth; Jotai is its in-memory cache. A single root
<Suspense>callsuse(idbHydrationPromise)once at startup; after that, everyatomWithIDBreads synchronously. - CLI-gate-first —
bun run checkrunsbiome ci → stylelint --max-warnings 0 → tsgo --noEmit → bun test → playwright test. Any warning is a failure.
| Layer | Tool | Version |
|---|---|---|
| Runtime / package manager | Bun | 1.3.13 (pinned via packageManager) |
| Tooling runtime | Node | 25 (.nvmrc) |
| Monorepo orchestrator | TurboRepo | 2.x |
| Language | TypeScript | 7 via @typescript/native-preview — the Go-based tsgo compiler; replaces tsc. Strict mode + noUncheckedIndexedAccess + exactOptionalPropertyTypes + verbatimModuleSyntax. |
| Framework | TanStack Start | SPA + full prerender (@tanstack/react-start) |
| Build engine | Nitro | v3 (used internally by @tanstack/react-start's prerender pipeline) |
| UI | React | 19 (Compiler enabled) |
| Styling | Tailwind | v4 (CSS-first via @theme) |
| Animation | anime.js | v4 (named imports only) |
| Canvas / 2D rendering | PixiJS | 8.18.1 — first-party for all canvas-based UI. Mounted via the usePixiApp(canvasRef, setup, deps) hook in app/canvas/. Same side-channel discipline as anime.js: render stays pure; all Application.init / Ticker / sprite mutation lives in useEffect; prefers-reduced-motion: reduce short-circuits Ticker animations. See the 24 pixijs-* skills under .claude/skills/ for the full API surface. |
| State | Jotai | 2.x (parameterized atoms via the atomWithIDB key + a module-scope Map<id, atom>; selectAtom from jotai/utils for derived per-id slices — dean-stack does not use atomFamily) |
| Persistence | IndexedDB via idb |
8.x |
| Validation | Zod | 4 |
| Env | @t3-oss/env-core |
client-only on Pages |
| TS / JS lint + format | Biome | 2.x |
| CSS lint | Stylelint + Tailwind plugin | 16.x |
| Component dev | Storybook | 10 (Vite builder) |
| PWA | Vite PWA + Workbox | latest |
| Unit tests | bun:test |
bundled with Bun |
| Browser tests | Playwright | 1.59+ |
This repo pins both runtimes; install matching versions before anything else.
The repo's .nvmrc pins Node 25. CI reads it (actions/setup-node@v4 with node-version-file: ".nvmrc"); locally, install Node 25 with nvm:
nvm install && nvm use
node -v # should print v25.x.xThe only Node-version pin in this repo is
.nvmrc. Don't add.tool-versionsor a"volta": {...}block inpackage.json—.nvmrc+ thepackageManagerfield already pin everything; a second pin format is two sources of truth waiting to drift.
Node only exists for tools that refuse to run on Bun. The app itself never executes on Node — the deploy target is GitHub Pages (static). Don't introduce a Node server.
Bun is pinned via the root package.json's packageManager field. Install:
# macOS / Linux / WSL
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
irm bun.sh/install.ps1 | iexVerify:
bun -v # should print 1.3.13Playwright runs against a real Chromium. Install the binary once per machine:
bunx playwright install chromiumCI installs this in the workflow.
git clone https://github.com/<owner>/dean-stack.git
cd dean-stack
bun install # installs every workspace; creates bun.lock
bunx playwright install chromium # browser binary (one-time per machine)
cp apps/web/.env.example apps/web/.env # local env fileOpen apps/web/.env and confirm VITE_GAME_TITLE is set (the committed default dean-stack is fine for development).
The text lockfile
bun.lockis committed. The older binary formbun.lockbis gitignored. Don't commit it.
Workspace-internal deps use the
workspace:*protocol — never a published version range. Bun creates symlinks undernode_modules/@dean-stack/*so Biome, Stylelint, and TS can extend the shared configs.
bun run devbun run dev invokes turbo run dev, which co-runs every persistent task in apps/web via Turbo's with co-runner:
| Task | URL | Owns |
|---|---|---|
dev |
http://localhost:5173 |
Vite dev server (TanStack Start in SPA mode) |
storybook |
http://localhost:6006 |
Storybook (component construction surface) |
biome:watch |
(terminal) | Lints .ts / .tsx / .js / .json on save |
stylelint:watch |
(terminal) | Lints .css on save |
The watchers print findings in the terminal — the IDE is not the source of truth and may not be running.
Edit apps/web/app/components/<name>/index.tsx — its sibling index.stories.tsx lights up immediately. Edit apps/web/app/styles/index.css — Tailwind tokens regenerate live. Add a route under apps/web/app/routes/ — TanStack Router regenerates routeTree.gen.ts automatically.
While developing, watch the browser console. A Zod runtime error in dev is a Pillar-2 contract failure — fix it before continuing, same as a TS error.
react-scan is loaded automatically in Storybook dev (.storybook/preview.tsx imports it behind an import.meta.env.DEV guard, so it tree-shakes out of the prod bundle). It outlines components that re-render with a highlighted box.
Not loaded in the app dev server. react-scan v0.5.x patches React 19 in a way that breaks TanStack Router's
HeadContent(useContextreturns null at the head-render boundary). Storybook avoids the issue because it doesn't renderHeadContent. Until the incompatibility is resolved upstream, do component-level re-render diagnostics in Storybook.
Use it. When you suspect a render issue — interaction feels janky, animation restarts unexpectedly, a Pixi canvas re-mounts, anything that "looks wrong" — open the route or story in your browser, perform the interaction, and look at the highlighted boxes. A box on a component that shouldn't have re-rendered is a real bug — usually:
- A side-channel violation (anime.js / PixiJS call leaking into render)
- An unstable atom return (a new object identity each
getinstead of a stable reference) - A missed React Compiler optimization (a Component-defined-inside-Component, a non-pure render, etc.)
Fix the cause, not the symptom. Manual useMemo / useCallback / React.memo is forbidden in dean-stack (the React Compiler handles memoization — see .claude/skills/react-compiler-rules/SKILL.md). Suppressing the highlight by adding manual memo defeats the diagnostic.
react-scan has no version-specific deprecations, no opinionated patterns, no migration cliffs — there is intentionally no skill for it. Just look at the boxes; fix what's bad.
If you want to focus on a subset:
# from apps/web/
bun run dev # Vite dev only
bun run storybook # Storybook only
bun run biome:watch # Biome lint watcher only
bun run stylelint:watch # Stylelint watcher onlyTwo flavours of the same gate. Any warning from any stage is a failure.
| Command | Chain | When |
|---|---|---|
bun run check |
biome ci → stylelint → tsgo → bun test → build → playwright (storybook + app + app-offline) |
CI, and any time you want full release-quality verification locally |
bun run check:fast |
biome ci → stylelint → tsgo → bun test → playwright --project=storybook |
Pre-push hook, and the inner-loop "is it green yet" check |
check:fast skips the app and app-offline Playwright projects because they spin up vite preview against dist/ — without a fresh build they validate yesterday's bytes. Those are CI's job. The storybook project drives storybook dev, which is HEAD-valid every run, so it's safe to gate locally.
bun run check # full gate
bun run check:fast # pre-push gate (also auto-runs on `git push`)If either is red, stop the current task, fix it, and re-run until green before proceeding.
bun install runs the prepare script which invokes bash scripts/install-hooks.sh — that writes a one-line .git/hooks/pre-push that execs bun run check:fast. No package dependency, no Bun-postinstall friction. Bypass intentionally with git push --no-verify (e.g. pushing a WIP branch you intend to clean up before opening the PR).
| Stage | Tool | Owns | Skipping rule |
|---|---|---|---|
lint |
biome ci |
.ts / .tsx / .js / .json (Biome's CSS linter is off — Stylelint owns CSS) |
Don't disable a rule to get green. Fix it. |
stylelint |
stylelint --max-warnings 0 |
.css only — knows Tailwind v4 directives via @dreamsicle.io/stylelint-config-tailwindcss |
Don't blanket-ignore @theme / @apply — install the plugin. |
typecheck |
tsgo --noEmit |
type-check only; emit is Vite's job | Don't as-cast at module boundaries — parse with Zod. |
test:unit |
bun test |
pure logic — schemas, atom reducers, IDB migration transforms, parsers | Don't reach for happy-dom — DOM tests belong in Playwright. |
test:e2e |
playwright test |
browser-tier — story tests + app workflows + offline deep-link | Don't test.skip to make CI green. |
- Don't
--no-verifyon git hooks. The hook runs the same gate. - Don't
// biome-ignorewithout a rule path and a justification. - Don't disable
--max-warnings 0in CI. Fix the warning.
Two layers, partitioned by what they need:
Fast, no browser. Use it for anything that doesn't need a DOM: pure functions, Zod schema edge cases, atom reducers, IDB migration transform functions, parsers, derived selectors.
bun test # full run (part of the gate)
bun test apps/web/app/state # path filter
bun test -t "rejects negative" # name filter
bun test --watch # local inner loop
bun test --coverage # coverage reportTests live next to their source: derive.ts ships with derive.test.ts in the same directory. Never split into __tests__/.
No ASK FIRST is needed for
bun test— write unit tests directly when the logic is unit-testable.
Three projects, three responsibilities, three test-name suffixes:
| Project | File suffix | URL | Purpose |
|---|---|---|---|
storybook |
*.story.spec.ts |
localhost:6006/iframe.html?id=... |
Mount a story, assert visible state, ARIA, IDB contents |
app |
*.app.spec.ts |
localhost:3000 (preview) |
End-to-end route workflows (interact → reload → verify) |
app-offline |
*.offline.spec.ts |
localhost:3000 (preview) |
Offline deep-link contract — the load-bearing PWA test |
bun run test:e2e # all projects
bun run test:e2e -- --project=app # app only
bun run test:e2e -- --project=app-offline # offline only
bun run test:e2e -- --project=storybook --grep @smoke # smoke subset
bun run test:e2e -- --ui # interactive Playwright UIStack-wide rules baked into playwright.config.ts:
- Reduced motion forced on at the project level (
use: { reducedMotion: 'reduce' }). TheuseAnimehook short-circuits, so animations don't add flake. Override per-test only if the animation IS the assertion. - Real IndexedDB — never mocked. Seed via
page.addInitScript; read viapage.evaluate. - Fresh IDB per test is the default fixture (
auto: true). - Web-first assertions only —
await expect(locator).toBeX(). Never wrap point-in-time methods insideexpect(). bun run previewis the target, notbun run dev. Vite dev does not register the production SW.
This is load-bearing per Pillar 4. Before writing or modifying any Playwright test, surface the structural choices to whoever owns the feature:
- Story-level vs app-level vs offline?
- What to assert: visible text, ARIA, screenshot, IDB contents, network calls?
- Selector strategy: role > test-id > text?
- IDB state: fresh, or seeded with what?
- Network: online, throttled, offline?
- Reduced motion: forced (default) or no-preference?
Calcifying these without consultation is a Pillar violation. Wait for the answer, then write the test.
bun run build # vite build (TanStack Start invokes Nitro for the static prerender)
bun run preview # serve the build at http://localhost:3000What bun run build does, in order:
- Validates env at build time.
vite.config.tsdoesimport "./app/env"as a side effect; aZodErrorhere aborts the build before any artifact is uploaded. - Vite bundles the SPA — React 19, the React Compiler, Tailwind v4.
- TanStack Start prerenders every route with
prerender.failOnError: true— a missing route fails the build. - Workbox writes
sw.jsprecaching the shell. - Build script copies
index.html→404.html(GH Pages's SPA fallback),touches.nojekyll(insurance against Jekyll stripping_-prefixed paths), then runsscripts/build-sitemap.tsto walk the prerendered HTML and emitsitemap.xmlkeyed offVITE_SITE_URL. The artifact lands atapps/web/dist/client/, withrobots.txt,llms.txt,og-card.svg, andsitemap.xmlalongsideindex.html.
Use bun run preview to verify locally — that's the same artifact GitHub Pages serves. Playwright app and app-offline projects run against this preview server, not the dev server.
Two GitHub Actions workflows:
.github/workflows/check.yml— runsbun run checkon every push and PR..github/workflows/deploy.yml— on push tomain, builds and uploads the artifact, deploys to Pages.
- Settings → Pages → Build and deployment → Source: GitHub Actions.
- Settings → Environments →
github-pages— created automatically by the deploy workflow on first run. - Settings → Secrets and variables → Actions → Variables — add
VITE_GAME_TITLEand the SEO contract (VITE_SITE_URL,VITE_SITE_DESCRIPTION, optionallyVITE_OG_IMAGE,VITE_AUTHOR_NAME,VITE_AUTHOR_URL,VITE_TWITTER_HANDLE). Defaults are baked into the workflow forVITE_SITE_URL(computed fromgithub.repository_owner/github.event.repository.name) andVITE_SITE_DESCRIPTION, so the minimum-viable deploy works without setting any vars — but overrideVITE_SITE_URLto your custom domain if you have one.- Values here are public — they ship in the JS bundle.
- Never put a real secret in a
VITE_*field. If you need a private key, GitHub Pages is the wrong target — surface the constraint, don't introduce a server.
on:
push: { branches: [main] }
workflow_dispatch:
inputs:
app:
description: "App to publish (folder name under apps/)"
default: web
type: string
env:
APP: ${{ github.event.inputs.app || vars.PAGES_APP || 'web' }}
steps:
- actions/checkout@v4
- actions/setup-node@v4 (with .nvmrc)
- oven-sh/setup-bun@v2 (reads packageManager pin)
- id: pages
uses: actions/configure-pages@v5 # emits the canonical base_path
- run: bun install --frozen-lockfile
- run: bunx playwright install --with-deps chromium
# Pillar 4 — gate runs BEFORE the prod build so we never deploy code
# that fails lint/types/unit/e2e. check.yml is PR-only; this is the
# only CI surface that runs on push to main (no parallel workflows).
- run: bun run check
- name: Build ${{ env.APP }}
env:
BASE_PATH: ${{ steps.pages.outputs.base_path }}
VITE_GAME_TITLE: ${{ vars.VITE_GAME_TITLE }}
run: bun run build --filter=@dean-stack/${{ env.APP }}
# build script handles cp index.html→404.html and touch .nojekyll
- actions/upload-pages-artifact@v3 (path: apps/${{ env.APP }}/dist/client)
- actions/deploy-pages@v4Which app gets published. inputs.app (workflow_dispatch) → vars.PAGES_APP (repo variable) → 'web' (default). Push triggers always pick up vars.PAGES_APP or fall back to web; workflow_dispatch lets you override per-run from the Actions UI.
Base path is env-driven, not hardcoded. actions/configure-pages@v5 outputs base_path — /<repo> for project pages (<owner>.github.io/<repo>/), / for user/org pages (<owner>.github.io), and / for custom domains. The workflow exports that as BASE_PATH, and vite.config.ts's resolveBase() normalizes it (adds the trailing slash Vite needs). Local dev: BASE_PATH is unset → /.
Configure the custom domain in Settings → Pages. actions/configure-pages@v5 will then emit an empty base_path, BASE_PATH is unset in the build env, and resolveBase() returns /. No code change required.
After the first successful deploy, smoke-test in this order:
- Online cold-load — open the project URL in a fresh browser, confirm the home route renders and the SW installs.
- Hard refresh — confirm the SW serves the shell from cache instantly.
- Offline deep-link — DevTools → Network → Offline → load
<URL>/games/maze/3directly. Confirm Level 3 renders.
If step 3 fails, the offline contract is broken — see apps/web/tests/maze-deep-link.offline.spec.ts for the regression test that should have caught it locally.
The single most important flow: a kid hitting a deep URL while offline must boot the SPA from cache, hydrate from IDB, and render — with no server round-trip. Lose this and the iPad-over-LAN scenario breaks.
Editing this diagram? Mermaid's sequence-diagram parser treats
+,;, and<followed by a letter (e.g.<LevelCard ...>) as syntax tokens inside message and note text — not literal characters. Avoid those, then validate withbunx --bun @mermaid-js/mermaid-cli -i diagram.mmd -o out.svgbefore pushing.
sequenceDiagram
autonumber
participant Kid as Kid (iPad, offline)
participant SW as Workbox SW
participant Bundle as Vite bundle
participant Router as TanStack Router
participant Suspense as Root Suspense
participant IDB as IndexedDB (idb)
participant Atom as atomWithIDB
participant Persist as persist.ts
participant Channel as BroadcastChannel
Note over Kid,SW: First visit was online, so SW and shell are precached.
Kid->>SW: navigate /games/maze/3
SW-->>Kid: precached index.html via navigateFallback
Kid->>Bundle: parse shell, evaluate JS
Bundle->>Suspense: import side-effect kicks off idbHydrationPromise
Bundle->>Router: createRouter, mount RouterProvider
Router->>Suspense: __root renders, use(idbHydrationPromise)
Suspense->>IDB: openDB(dean-stack, v2)
IDB->>IDB: upgrade callback runs cumulative migrations
IDB-->>Suspense: connection ready
Suspense->>IDB: getAll(progress), get(settings)
IDB-->>Suspense: raw rows
Suspense->>Suspense: Zod safeParse rows, set resolvedSnapshot
Suspense-->>Router: hydration promise resolved
Router->>Router: ParamsSchema.parse coerces level to 3
Router->>Atom: read getProgressAtom for id maze-3
Atom->>Atom: lazy resolveCurrent, snapshot miss returns fallback
Atom-->>Router: Progress id=maze-3 level=1 completed=false
Router-->>Kid: render LevelCard with URL level=3, atom completed=false
Note over Kid,Channel: Kid taps Complete, write-through path.
Kid->>Atom: setProgress with completed=true
Atom->>Atom: ProgressSchema.parse(next), Pillar 2 contract
Atom->>Atom: update in-memory storage atom
Atom->>Persist: call persistProgress(value)
Persist->>Persist: schedule with 150ms debounce, key progress:maze-3
Persist->>IDB: db.put(progress, value)
IDB-->>Persist: write committed
Persist->>Channel: postMessage on dean-stack:idb
Channel-->>Kid: other tabs and Storybook iframe re-hydrate
Four load-bearing properties this diagram encodes:
- No network. Steps 1–3 use only the SW's precache. The router resolves
/games/maze/3client-side and never asks a server — that route is not in the prerender list, so Workbox'snavigateFallback: "/index.html"(with a denylist that excludes/assets/*, image/font extensions, and/sw.js) hands back the canonical shell and the router takes over from there. - Single Suspense, eager hydration.
idbHydrationPromiseis a top-level IIFE inapps/web/app/state/hydration.ts— step 4 is the import side-effect kicking it off, not a function call. By the time__root.tsxcallsuse(idbHydrationPromise)(step 6),openDBis usually already pending. AfterresolvedSnapshotis populated (step 12), everyatomWithIDBresolves its initial read synchronously viagetHydratedSnapshot()— no per-atom suspense, no waterfalls. - Two-stage URL → state resolution. Step 14 (
ParamsSchema.parse) coerces the URL segment to a typedlevel: numberviaz.coerce.number().int().min(1).max(99). Step 16 returns the atom'sfallback({ id, level: 1, completed: false }) because IDB is empty on a cold offline boot — the renderedLevelCarddisplayslevel=3from the URL andcompleted=falsefrom the fallback, even though no progress row exists yet. Writing one (step 19) creates it. - Parse on every set, debounced write-through. Step 20 is Pillar 2 in action —
ProgressSchema.parse(next)insideatomWithIDB's setter (apps/web/app/lib/atom-with-idb.ts:36). An invalid set throws and propagates to the rooterrorComponent. Steps 22–24 isolate IDB writes from React's render pipeline:persist.tsschedules a 150ms timer per key (progress:maze-3), coalescing rapid taps into a singledb.put, then posts{ store, key }on thedean-stack:idbBroadcastChannel so other tabs and the Storybook iframe re-hydrate. The value itself is not on the channel — the receiver re-reads from IDB, keeping IDB the single source of truth (Pillar 3).
This contract is verified end-to-end by apps/web/tests/maze-deep-link.offline.spec.ts — currently .skip'd pending the Workbox / TanStack-Start _shell.html rename ordering fix (see Known gaps).
Add a new dean-stack app — same toolchain, same Pillars, same gate — with the TurboRepo generator at turbo/generators/config.ts.
bun gen:app # interactive prompt for the name
# or, equivalent:
bunx turbo gen run app # interactiveThe generator asks for a kebab-case name (e.g. test-project), then scaffolds apps/<name>/ from turbo/generators/templates/app/:
- Full toolchain wiring (Vite + TanStack Start + Tailwind v4 + Storybook + Playwright + Biome + Stylelint)
- Pillar-3 state plumbing (db, hydration, persist, atomWithIDB, migration test)
- Pillar-2 helper (
defineComponent) - Motion plumbing (
useAnime, presets, engine defaults) - One seed component (
HealthCard) with co-located story and Playwright story test - A skipped offline shell test (re-enable once SW generation is wired)
After scaffolding:
bun install # symlinks the new workspace
bun run check # runs the gate across every appThe generator templates substitute {{name}} everywhere it matters: package.json's @dean-stack/<name>, the IDB database name, the BroadcastChannel name, the PWA manifest, the index.html <title>, the vite.config.ts base path for project pages, and the default VITE_GAME_TITLE in .env.
The repo's deploy workflow (
/.github/workflows/deploy.yml) currently builds and uploadsapps/webonly. To deploy a generator-scaffolded app, either point the workflow at the new app'sdist/client/or add a parallel workflow per app.
dean-stack/
├── apps/
│ └── web/ # the TanStack Start app
│ ├── app/
│ │ ├── routes/ # file-based routes (TanStack Router)
│ │ ├── components/ # each: index.tsx + schema.ts + stories.tsx + (test.ts)
│ │ ├── state/ # db, hydration, persist, atoms, migrations
│ │ ├── motion/ # useAnime + presets + engine defaults (anime.js side channel)
│ │ ├── canvas/ # usePixiApp hook (PixiJS side channel for canvas UI)
│ │ ├── lib/ # defineComponent, atomWithIDB
│ │ ├── styles/ # Tailwind entry + @theme tokens
│ │ └── env.ts # T3 env (build-time validation)
│ ├── tests/ # Playwright specs + fixtures
│ ├── .storybook/
│ ├── vite.config.ts
│ ├── vite.shared.ts # plugins shared with Storybook (NEVER fork)
│ └── playwright.config.ts
├── packages/
│ ├── schemas/ # shared Zod schemas (Score, Settings, Progress)
│ ├── tsconfig/ # base.json — every workspace extends this
│ ├── biome-config/ # extended by root biome.json (root: false)
│ └── stylelint-config/ # extended by root stylelint.config.mjs
├── turbo/
│ └── generators/
│ ├── config.ts # `turbo gen run app` — scaffolds new apps
│ └── templates/app/ # template tree mirroring apps/web minus demos
├── .github/workflows/
│ ├── check.yml # the gate, on every push/PR
│ └── deploy.yml # GH Pages, on push to main
├── biome.json # extends @dean-stack/biome-config
├── stylelint.config.mjs # extends @dean-stack/stylelint-config
├── tsconfig.json
├── turbo.json # task graph + with-co-runners for dev
├── package.json # workspaces + packageManager pin
└── CLAUDE.md # full architectural spec — read first
Every PR is reviewed against the Four Pillars. Anything that violates one is reverted, not patched.
- Every new
*.tsxcomponent has a sibling*.stories.tsx? - Stories are co-located with the component (no
__stories__/parallel tree)? - No component was built at the route level first?
- Storybook config (
viteFinal) re-usesvite.shared.ts— not forked?
- Every component prop schema is a
z.object? - No hand-written TS type that mirrors an existing Zod schema?
- Boundary parses use
safeParse/parse— noascasts at module boundaries? - No
any—unknown+ Zod parse instead? - Components use
defineComponent(schema, fn)so dev parses and prod tree-shakes?
- Persistent state goes through
atomWithIDB, neveruseState? - Only one
<Suspense>boundary usesuse(idbHydrationPromise)(root)? - No per-atom suspense?
- Schema bumps include a migration test (
bun test)? - The service worker doesn't touch IDB?
-
bun run checkis green locally? - Every Playwright test was preceded by an ASK-FIRST round?
- No rule disabled, no
// biome-ignorewithout a justification, notest.skip?
- No manual
useMemo/useCallback/React.memo? - No anime.js or PixiJS call inside render — both are side channels; all
animate(),new Application(),Ticker.add(...), sprite mutation, and DOM/canvas reads live insideuseEffect/useLayoutEffect/ event handlers? - No
ref.current = ...during render? - Every animation site uses
useAnime(anime.js) orusePixiApp(PixiJS) — both short-circuit onprefers-reduced-motion?
-
__root__.tsxdeclareserrorComponentandnotFoundComponent? -
router.tsxwiresdefaultErrorComponentanddefaultNotFoundComponent? -
RouteErrorre-throws whentypeof window === "undefined"so prerender'sfailOnErroraborts on real bugs? -
app/router.test.tsstill passes — the four wirings are gate-asserted, not a convention? - A new game route declares its OWN
errorComponentfor level-tight recovery (root is the safety net, not the first line)?
- Workbox
navigateFallbackstill points at/index.html, denylist still excludes asset URLs? - No new precache entry for per-route data (assets only)?
- No fetch from inside the service worker?
- Any new
VITE_*env var is added inapps/web/app/env.tsand in.github/workflows/deploy.yml'senv:block? - No entries in
app/env.ts'sserver: {}slot (GH Pages is static)? - Any new prerendered route is reachable via
<Link>from another prerendered page (so TanStack Start'scrawlLinkspicks it up) or listed in thetanstackStart({ prerender: { routes: [...] } })array?
- Any new top-level route declares its own
headreturningbuildSeoLinks({ path })so a single<link rel="canonical">renders (root no longer emits canonical — seeapps/web/app/lib/seo.ts)? - Per-route titles or descriptions go through
buildSeoMeta({ path, title, description })so OG and Twitter Card tags update in lockstep with the page title? - No raw
<meta>JSX in components — head injection is route-level via TanStack Router'sheadcallback, not React tree? -
tests/seo.app.spec.tsstill passes — the SEO contract is gate-asserted, not a convention?
The gate runs green today, but three Playwright tests are explicitly test.skip'd with documented reasons. They mark contracts that are real but blocked on upstream wiring:
tests/shell.offline.spec.tsandtests/maze-deep-link.offline.spec.ts— the PWA offline contract.vite-plugin-pwa'scloseBundlehook runs before TanStack Start's prerender renames the SPA shell from_shell.htmltoindex.html, so nosw.jslands indist/client/. Fix: a post-prerender Workbox step (probably a custom Vite plugin invoking Workbox after the rename), or adoptinjectManifestmode and write the SW by hand. Re-enable oncedist/client/sw.jslands.tests/maze-level.app.spec.ts— clicking the Complete button callssetProgress(...)on the parameterizedgetProgressAtom(id)atom, the schema parses, IDB write is scheduled, but theLevelCardkeeps showing the "Complete" button branch instead of the "Completed" badge. The atom's stored value flips internally; the consuminguseAtomdoesn't observe the new value until a remount. Confirmed independent of the family layer (un-skipped after the parameterized-atoms rewrite that replacedjotai-familywith module-scopeMap<id, atom>memoization, and the test failed identically). Suspect the SENTINEL-to-resolved transition insideatomWithIDB's lazy-read pattern. Re-enable onceatomWithIDB's read path is fixed.
Both are tracked as test-skip annotations in-tree; remove the .skip once the underlying fix lands.
tsgo --noEmit fails with routeTree.gen.ts errors after a fresh bun install.
The committed stub has @ts-nocheck and is regenerated on bun run dev. Run bun run dev once, let the TanStack Router plugin overwrite the file, then re-run bun run check.
Playwright app-offline test fails locally but passes in CI (or vice versa).
The offline test runs against bun run preview, not bun run dev. Vite dev does not register the production SW. Stop dev, run bun run build then bun run preview, and re-run bun run test:e2e -- --project=app-offline.
Biome can't resolve @dean-stack/biome-config/biome.json.
Run bun install so workspace symlinks land in node_modules/@dean-stack/. If it still fails, swap to a relative extends in biome.json: "extends": ["./packages/biome-config/biome.json"].
prerender.failOnError aborts the build with a missing route.
Add the route to the tanstackStart({ prerender: { routes: [...] } }) array in apps/web/vite.config.ts, or ensure a <Link to="..."> from a prerendered page reaches it (crawlLinks: true will pick it up). Don't set failOnError: false — the missing route is the bug.
Stylelint flags @theme / @apply / @import "tailwindcss".
The Tailwind plugin (@dreamsicle.io/stylelint-config-tailwindcss) isn't installed or stylelint.config.mjs isn't extending @dean-stack/stylelint-config. Re-run bun install.
A Zod parse fails in the dev browser console. That's a Pillar-2 contract failure of the same severity as a TS error. Fix the schema or the input — never silence the throw.
bun.lockb shows up in git status.
You're on an old Bun version that wrote the binary lockfile. Upgrade to Bun ≥ 1.2 and delete bun.lockb. The text bun.lock is the only committed lockfile.
bun run check fails on tsc because of T3 env's generic types.
isolatedDeclarations is intentionally off in packages/tsconfig/base.json until the env wiring is annotated. If you've turned it on locally, turn it off or annotate the env export explicitly.
Storybook builds but Tailwind classes don't apply in stories.
The Tailwind plugin is in vite.shared.ts and Storybook re-uses it via viteFinal. If classes are missing, somebody forked the Vite config in .storybook/main.ts and added @tailwindcss/vite separately, which double-runs the plugin. Remove the duplicate.
bun run dev says "no tasks were found" or watchers don't start.
Each watcher is a Turbo task with with: ["dev"] in turbo.json, paired with a script in apps/web/package.json. If you've renamed one without updating the other, Turbo can't pair them. Check both files match.
Storybook Playwright tests fail with ERR_CONNECTION_REFUSED on the first run after a bun install.
Vite cold-prebundles new dependencies the first time Storybook starts. The Storybook webServer in playwright.config.ts has timeout: 180_000 (3 min) to cover this, but a parallel-worker race against the prebundler can still surface as connection refused. Re-run bun run check once — the second run hits the warm prebundle cache and goes green. This is the documented "retry once on infra flake" rule (Pillar 4).
- CLAUDE.md — the full architectural spec, including the why behind every constraint.
.claude/skills/_OWNERSHIP_MATRIX.md— which skill owns which API surface.apps/web/tests/maze-deep-link.offline.spec.ts— the load-bearing offline-deep-link test the architecture diagram describes.