Modernize the template: Expo 57, React 19, Prisma 7, and design system tokens - #1
Merged
Merged
Conversation
…nd pnpm 11 The template had drifted about two years behind. This re-pins every layer to the current supported set and fixes the breakages that came with it. Runtime and framework: - Expo 51 -> 57, React 18.2 -> 19.2.3, React Native 0.74 -> 0.86.3, all pinned by `expo install --fix` rather than by hand. - Fastify 4 -> 5, @fastify/cors 9 -> 11, tRPC 11.0 -> 11.18. - Node engine raised to >=24 so package.json agrees with .nvmrc. Prisma 7 is a real migration, not a version bump: - The datasource block no longer accepts `url`; the CLI reads it from the new prisma.config.ts and the runtime client gets it from a @prisma/adapter-pg driver adapter. - The generator moved from `prisma-client-js` to `prisma-client`, which emits into the source tree (gitignored) instead of node_modules. Zod 4 moved the string formats to top-level schemas, so `z.string().uuid()`, `.email()` and `.url()` became `z.uuid()`, `z.email()` and `z.url()`. pnpm 11 no longer reads `node-linker` from .npmrc. That silently dropped the hoisted layout Metro depends on, so it moves to pnpm-workspace.yaml as `nodeLinker: hoisted`, alongside the `allowBuilds` approvals pnpm 10+ requires. Two upgrades were deliberately held back, both blocked by the toolchain rather than by choice: - TypeScript stays on 6.0.3: ts-jest caps at <7 and typescript-eslint at <6.1. - Jest stays on 29 across every package because jest-expo 57 requires it. Test and type fixes this forced: - React Native Testing Library 14 made `render` and `fireEvent` async, so both suites now await them, and `extend-expect` is gone (matchers are built in). - packages/ui was resolving its own react-native copy via a `*` peer, so NativeWind's className augmentation landed on the wrong module. It now pins react/react-native to the app's versions. - TypeScript 6 stopped auto-discovering @types here, so each tsconfig names its test types explicitly, matching what apps/api already did. Verified: pnpm lint, pnpm typecheck, pnpm test and pnpm build all pass, and the web export bundles 868 modules with NativeWind CSS emitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The template carried its own three-colour palette (primary/surface/muted) with
no relationship to the design system every other project here uses. This wires
the real tokens in.
React Native cannot parse oklch() or follow var() chains, and a Tailwind config
is synchronous CommonJS while the design system's parser is ESM. So the tokens
are converted ahead of time by packages/config/tailwind/sync-tokens.mjs, which
uses the design system's own parser and colour helpers rather than restating
what a token means:
palettes.css + tokens.css + mobile.css
-> tokens.generated.js Tailwind theme; colours as var() names
-> tokens.generated.css :root and .dark blocks with the real hex
Three things worth knowing about the conversion:
- The mobile platform layer is the baseline, not an override. mobile.css recuts
radii, the small end of the type ramp, gutters and container widths for a
device held at arm's length, so rounded-md is 12px here rather than the
desktop 6px. It touches no colour by design, so the system's contrast
assertions still hold.
- The neutral ramp writes its chroma as calc(0.002 * var(--n-mult)), which the
colour parser cannot read. The generator evaluates plain-number calc() first,
and only plain-number calc() — anything else is left to fail loudly at toHex.
- The parser reports the winning value per token but not which selector won it,
so the dark and mobile layers are lifted out of their blocks and re-fed as if
they were :root. That is the only way to read more than one theme.
Colours resolve through var(--ds-*) rather than literal hex, which is what lets
one class pick up its dark value; darkMode stays 'class' because Expo Router
sets the colour scheme programmatically. The generator exits non-zero if any
colour token fails to convert, so a new token shape breaks the sync rather than
silently dropping a colour.
The demo components now name tokens (bg-accent, text-fg-2) instead of the old
palette, and HomeScreen moves to react-native-safe-area-context's SafeAreaView
since React Native's own is deprecated and warned on every render.
AGENTS.md is rewritten around this: what the bridge does, and a Toolchain
constraints section recording why TypeScript is held at 6, Jest at 29 and
Tailwind at 3, so the next agent does not re-derive those blockers.
Verified: lint, typecheck and tests pass in every package, and the web export
emits :root/.dark token variables with bg-accent resolving to var(--ds-accent).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installed from GitHub Packages rather than a local link. The generated Tailwind theme is byte-identical to what the local checkout produced, so the bridge needs no change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…isted app.json has pointed at ./assets/icon.png since the initial commit, but that file — and the whole assets/ directory — has never been in the repo. The web build does not read it, so nothing caught it until the first native build: `expo prebuild` dies with ENOENT before it can generate the iOS project. Removing the key lets prebuild use Expo's default icon, which is the right default for a template. A project started from it adds its own icon and puts the key back. Found while trying to run the app on a simulator; prebuild and pod install both complete after this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ative
Running the app on a simulator caught three things the web build could not.
**Dark mode never applied on a device.** The token variables shipped as a
`.dark { … }` block in a stylesheet the app `@import`ed. NativeWind's CSS-to-RN
compiler only recognises `.dark:root` — not `.dark` — as a dark root-variable
block, and only after it has read the `@cssInterop set darkMode class dark`
at-rule that nativewind/preset emits. Both halves of that matter:
- `.dark` alone is not a root-variable selector to its normaliser, so the dark
values were dropped and the phone stayed light while the web build honoured
them.
- The order is strict. Variables parsed before that at-rule keep only their light
half, and an `@import` can never come after it, because postcss-import inlines
imports only at the top of the file. Moving the import to the bottom does not
help either — postcss-import drops it silently and the tokens vanish from the
build entirely, which is how it first failed.
So the variables now come from an `addBase` plugin in the preset instead of a
generated stylesheet. Presets apply in order, so listing nativewind/preset first
puts its at-rule ahead of them. tokens.generated.css is gone; the generator emits
only tokens.generated.js.
**Nothing told NativeWind to follow the OS.** darkMode is 'class', which means no
automatic behaviour at all; _layout.tsx now calls `colorScheme.set('system')`.
**The Button was under the touch target floor.** Measured 40pt on device against
the design system's `--target: 44px` for primary controls, which is also Apple's
HIG minimum. The `--target` family is now a `minHeight` scale and Button carries
`min-h-target`. The scale keys keep the `target` name rather than Tailwind's
DEFAULT so the class reads `min-h-target` and not a bare `min-h`.
Verified on an iPhone 17 Pro simulator (iOS 26.5) by sampling pixels rather than
eyeballing: light background #ffffff and dark #000000 swapping with the OS
setting, accent #ef8a00 in both, and the button measured at exactly 132px = 44.00pt.
lint, typecheck, test and build all pass, and the web bundle still carries the
:root/.dark:root blocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@elirobinson/tokens is published to GitHub Packages, which the runner has no credential for by default, so `pnpm install --frozen-lockfile` failed with a 401 and "No authorization header was set for the request". It passed locally only because a developer's ~/.npmrc already carries a token. Both jobs now write the existing NODE_AUTH_TOKEN secret into the runner's ~/.npmrc before installing — the same step next-template uses. It goes to the home-directory .npmrc rather than the repo's, so no token ever lands in a checked-in file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing the token into ~/.npmrc was not enough. pnpm 11 keeps its own credential store and only attaches tokens from there: the previous run listed the token back in pnpm's "These authorization settings were found" output and still failed with a 401 saying no authorization header was set for the request. `pnpm config set` writes where pnpm 11 actually reads. This is the same trap that made the package unresolvable locally, where a stale token in pnpm's store shadowed a working one in ~/.npmrc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It did its job: the run printed "token length: 0", so the NODE_AUTH_TOKEN secret on this repo is empty. That is why pnpm reported the setting as found and still sent no authorization header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`import '../global.css'` is how the Tailwind/NativeWind entry reaches the bundle. Expo declares `*.css` in its generated expo-env.d.ts, but that file is gitignored and only written by an `expo` command, so a clean checkout does not have it. CI typechecks without ever running expo, and failed with: app/_layout.tsx(1,8): error TS2882: Cannot find module or type declarations for side-effect import of '../global.css'. TypeScript 6 is what surfaced it — 5.x accepted an undeclared side-effect import silently, so this was latent on the old toolchain rather than new. A tracked css.d.ts declares it, which keeps expo-env.d.ts gitignored as Expo intends. Verified by moving the generated file aside and typechecking, which reproduces the CI failure exactly and passes with this file present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The root test script was `turbo run test --`, and CI invokes it as `pnpm test -- --ci --coverage`. The two `--` compounded: turbo forwarded the second one along with the flags, so jest saw them as positional arguments and looked for tests whose names matched, finding none: No tests found, exiting with code 1 Pattern: --ci|--coverage - 0 matches Dropping the trailing `--` from the script makes it `turbo run test -- --ci --coverage`, which reaches the workspaces as `jest --ci --coverage`. Latent rather than new: a bare `pnpm test` passes either way, which is what local runs and the pre-upgrade gates used, so only the CI invocation exercised it. Both forms verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The template had drifted about two years behind — Expo 51, React 18.2, Prisma 5, pnpm 9 — and carried its own three-colour palette with no relationship to the design system every other project here uses. This brings both current.
Verified on an iPhone 17 Pro simulator (iOS 26.5), not just the web build. That caught three real bugs the web build cannot surface; see Found by device testing below.
1. Toolchain (
3f3cb6f)Expo's own
expo install --fixpinned the SDK-managed versions rather than my guessing them.package.json's Node engine now agrees with.nvmrc(24), which it did not before.Prisma 7 is a migration, not a bump. The datasource block no longer accepts
url; the CLI reads it from a newapps/api/prisma.config.tsand the runtime client gets it from a@prisma/adapter-pgdriver adapter. The generator moved toprisma-client, which emits into the source tree (gitignored).pnpm 11 stopped reading
node-linkerfrom.npmrc. That silently dropped the hoisted layout Metro depends on — type and test gates stayed green whileexpo startwould have broken. It moves topnpm-workspace.yamlasnodeLinker: hoisted, alongside theallowBuildsapprovals pnpm 10+ now requires.Breakages this surfaced and fixed:
renderand everyfireEventhelper async; both suites now await them.extend-expectis gone — matchers self-register.packages/uideclaredreact-nativeas a*peer, so pnpm installed it a second copy. NativeWind'sclassNametype augmentation landed on the wrong module, givingProperty 'className' does not exist on type ...PressableProps. It now pins react/react-native to the app's exact versions.@typesin this layout, so each tsconfig names its test types explicitly.2. Design system tokens (
264a318)Styling now comes from
@elirobinson/tokens: 109 colours plus spacing, radius, type, weight, line-height, tracking, z-index, duration, container and touch-target scales.React Native cannot parse
oklch()or followvar()chains, and a Tailwind config is synchronous CommonJS while the design system's parser is ESM. Sopackages/config/tailwind/sync-tokens.mjsconverts ahead of time, using the design system's own parser and colour helpers rather than restating what a token means. Runpnpm tokens:syncafter bumping the package.Three details worth review:
mobile.cssrecuts radii, the small end of the type ramp, gutters and container widths for a device at arm's length, sorounded-mdis 12px here rather than the desktop 6px. It touches no colour by design, so the system's contrast assertions still hold.calc(0.002 * var(--n-mult)), which the colour parser cannot read. The generator evaluates plain-numbercalc()only; anything else is left to fail loudly attoHex.:root. That is the only way to read more than one theme.The generator exits non-zero if any colour fails to convert, so a new token shape breaks the sync instead of silently dropping a colour.
3. Lockfile (
77060df)Installs
@elirobinson/tokens@0.15.0from GitHub Packages. The generated theme is byte-identical to what a local checkout produced.Found by device testing (
f6134df,407a5bd)A dangling icon reference, unnoticed since the initial commit.
app.jsonpointed at./assets/icon.png; that file and the wholeassets/directory have never existed. Web builds never read it, so nothing caught it untilexpo prebuild, which dies with ENOENT before it can generate the iOS project.Dark mode never applied on a device, while the web build honoured it. Two independent causes:
.dark { … }block. NativeWind's CSS-to-RN compiler only recognises.dark:root, and only after it has read the@cssInterop set darkMode class darkat-rule thatnativewind/presetemits. Order is strict.@importcan never satisfy that order — postcss-import inlines imports only at the top of the file, necessarily before that at-rule. Moving the import to the bottom is worse: postcss-import drops it silently and the tokens leave the build entirely.Both failure modes are silent and both look correct on web. The variables now come from an
addBaseplugin in the preset, which puts them after nativewind's base layer. Separately,darkMode: 'class'means nothing follows the OS on its own, so_layout.tsxcallscolorScheme.set('system').The Button was under the touch target floor. Measured 40pt against the design system's
--target: 44pxfor primary controls, which is also Apple's HIG minimum.--targetis now aminHeightscale and Button carriesmin-h-target.Deliberately held back
Each was attempted and blocked by the toolchain, not by preference:
ts-jestpeers<7,typescript-eslintpeers<6.1.0.jest-expo@57requires 29, and the hoisted layout means one jest major serves the workspace.react-native-css-interop, which targets Tailwind 3's JS config. Tailwind 4 needs NativeWind 5, which is preview-only.AGENTS.mdrecords all of these, plus theaddBaseordering rule, in a Toolchain constraints section so the next reader does not re-derive them.Verification
pnpm lint,pnpm typecheck,pnpm testandpnpm buildall pass from a clean install.On the simulator, measured by sampling pixels rather than eyeballing:
#ffffff, dark#000000, swapping live with the OS setting#ef8a00in both themes — the design system'ssignal-500Not verified: Android. This machine has JDK 11 and Android platforms 29–32; Expo 57 needs JDK 17+ and a newer platform. Worth a run before anyone relies on the Android target.
🤖 Generated with Claude Code