Skip to content

Modernize the template: Expo 57, React 19, Prisma 7, and design system tokens - #1

Merged
EliRobinson merged 11 commits into
mainfrom
chore/modernize-template
Sep 5, 2026
Merged

EliRobinson merged 11 commits into
mainfrom
chore/modernize-template

Conversation

@EliRobinson

@EliRobinson EliRobinson commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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)

Was Now
Expo 51 57
React / React Native 18.2 / 0.74 19.2.3 / 0.86.3
Prisma 5 7
Zod 3 4
Fastify 4 5
TypeScript 5.5 6.0
ESLint 9 10
pnpm 9.7 11.25

Expo's own expo install --fix pinned 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 new apps/api/prisma.config.ts and the runtime client gets it from a @prisma/adapter-pg driver adapter. The generator moved to prisma-client, which emits into the source tree (gitignored).

pnpm 11 stopped reading node-linker from .npmrc. That silently dropped the hoisted layout Metro depends on — type and test gates stayed green while expo start would have broken. It moves to pnpm-workspace.yaml as nodeLinker: hoisted, alongside the allowBuilds approvals pnpm 10+ now requires.

Breakages this surfaced and fixed:

  • React Native Testing Library 14 made render and every fireEvent helper async; both suites now await them. extend-expect is gone — matchers self-register.
  • packages/ui declared react-native as a * peer, so pnpm installed it a second copy. NativeWind's className type augmentation landed on the wrong module, giving Property 'className' does not exist on type ...PressableProps. It now pins react/react-native to the app's exact versions.
  • TypeScript 6 stopped auto-discovering @types in 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 follow var() chains, and a Tailwind config is synchronous CommonJS while the design system's parser is ESM. So packages/config/tailwind/sync-tokens.mjs converts ahead of time, using the design system's own parser and colour helpers rather than restating what a token means. Run pnpm tokens:sync after bumping the package.

Three details worth review:

  • 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 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 chroma as calc(0.002 * var(--n-mult)), which the colour parser cannot read. The generator evaluates plain-number calc() only; 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.

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.0 from 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.json pointed at ./assets/icon.png; that file and the whole assets/ directory have never existed. Web builds never read it, so nothing caught it until expo 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:

  • The variables shipped as a .dark { … } block. NativeWind's CSS-to-RN compiler only recognises .dark:root, and only after it has read the @cssInterop set darkMode class dark at-rule that nativewind/preset emits. Order is strict.
  • An @import can 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 addBase plugin in the preset, which puts them after nativewind's base layer. Separately, darkMode: 'class' means nothing follows the OS on its own, so _layout.tsx calls colorScheme.set('system').

The Button was under the touch target floor. Measured 40pt against the design system's --target: 44px for primary controls, which is also Apple's HIG minimum. --target is now a minHeight scale and Button carries min-h-target.

Deliberately held back

Each was attempted and blocked by the toolchain, not by preference:

  • TypeScript 7ts-jest peers <7, typescript-eslint peers <6.1.0.
  • Jest 30jest-expo@57 requires 29, and the hoisted layout means one jest major serves the workspace.
  • Tailwind 4 — NativeWind 4 is built on react-native-css-interop, which targets Tailwind 3's JS config. Tailwind 4 needs NativeWind 5, which is preview-only.

AGENTS.md records all of these, plus the addBase ordering rule, in a Toolchain constraints section so the next reader does not re-derive them.

Verification

pnpm lint, pnpm typecheck, pnpm test and pnpm build all pass from a clean install.

On the simulator, measured by sampling pixels rather than eyeballing:

  • Light background #ffffff, dark #000000, swapping live with the OS setting
  • Accent #ef8a00 in both themes — the design system's signal-500
  • Button height exactly 132px = 44.00pt
  • Counter increments on tap

Not 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

EliRobinson and others added 11 commits September 4, 2026 17:28
…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>
@EliRobinson
EliRobinson merged commit 942d63b into main Sep 5, 2026
2 checks passed
@EliRobinson
EliRobinson deleted the chore/modernize-template branch September 5, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant