WI-023: Expo scaffold
WI-023: Expo scaffold
Problem
There is no mobile app. WI-030 builds collection management across API, web and mobile simultaneously, and discovering then that Metro cannot resolve a workspace package would block a vertical slice rather than a scaffold.
The scaffold exists to find those problems while they are cheap.
In scope
apps/mobile— Expo SDK 57, Expo Router, TypeScript.- Metro configured for a pnpm workspace, which is the part that actually breaks.
- Two routes, one of them dynamic, so file-based routing is exercised rather than assumed.
- Pure display helpers consuming
@tabletop/shared, with tests. - A runtime import of a shared value, not just a type.
expo export --platform webwired into the gate as the app’sbuild.- A bundle budget.
Out of scope
- Any network call. The API does not exist (WI-012, blocked on WI-H06).
- Authentication — WI-013.
- Native builds, EAS, store metadata. Those need paid accounts (WI-H02, WI-H03) and are deliberately deferred so the billing clock does not start early.
- Component rendering tests. Those need a native-aware runner and belong with the first real screen.
Why expo export --platform web is the build
It is the only Expo build that runs without Xcode or the Android SDK, so CI can do it on a free runner. More usefully, it exercises the thing that actually breaks in a monorepo: Metro resolving workspace packages through pnpm’s symlinks. A native build would not test that any harder.
The type-only trap
import type is erased before bundling. A scaffold that only imports types from @tabletop/shared will bundle cleanly while every runtime import from that package is broken — and the failure surfaces at WI-030, in a vertical slice, rather than here.
So the [slug] route imports and calls slugSchema at runtime. That import is load-bearing: remove it and the bundle stops proving anything.
Definition of done
-
apps/mobilelints, typechecks, tests and builds in the workspace gate. -
expo export --platform websucceeds and emits the dynamic route as well as the static ones. - A runtime value from
@tabletop/sharedis imported and used, so the contract is proven rather than assumed. - Display helpers cover the nullable columns the catalogue actually has — a game with no designers, no player count and no playing time renders words rather than
nullorNaN. - Metro resolution is fixed in
metro.config.jsand the reason is written down, because every line of it looks removable. - The bundle has a budget that fails the build when exceeded.
- Gate green.
Verification
pnpm --filter @tabletop/mobile testpnpm --filter @tabletop/mobile buildpnpm gateWhat this found
pnpm needs hierarchical lookup; the common advice says to disable it. disableHierarchicalLookup circulates as the fix for duplicate React in hoisted Yarn and npm monorepos. Under pnpm a package’s dependencies live in its sibling directory inside .pnpm, and hierarchical lookup is precisely how they are found. Setting it broke resolution of expo-modules-core from expo itself.
Transitive peers must be declared. @expo/router-server requires expo-font, which pnpm does not hoist, so nothing could reach it. Declared explicitly in apps/mobile.
Metro does not map .js specifiers onto .ts sources. Workspace packages ship TypeScript and their main points at src/index.ts; Node ESM requires the extension, so they import ./schemas.js while the file is schemas.ts. Node and tsx map that, Metro resolves the literal path and fails. A resolveRequest shim handles it, scoped to packages/.
The contract carries the ORM. One runtime value from @tabletop/shared grew the bundle from 1.1MB to 1.79MB, pulling in pg-core and drizzle-zod. That is a fork ADR-0008 did not anticipate, recorded as ADR-0022 and deliberately left unresolved — it changes how every client consumes the contract, which is not a decision to take while building a scaffold. The cost is fenced by the bundle budget in the meantime.