Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/olive-hounds-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@repo/config': patch
'@repo/ui': patch
---

Fix dark mode and the touch target floor on native.

The token variables move from an `@import`ed stylesheet into an `addBase` plugin,
which is the only ordering NativeWind accepts for `.dark:root` on native. Adds a
`minHeight` scale from the design system's `--target` tokens, and `@repo/ui`'s
Button now carries `min-h-target` so it meets the 44px floor.
16 changes: 16 additions & 0 deletions .changeset/tall-pears-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@repo/config': minor
'@repo/ui': minor
---

Drive all styling from the `@elirobinson/tokens` design system.

`@repo/config/tailwind` is now generated from the design system's stylesheets by
`pnpm tokens:sync` rather than hand-written: 109 colours plus the spacing,
radius, type, weight, line-height, tracking, z-index, duration and container
scales. The mobile platform layer is the baseline, so radii and the small end of
the type ramp are the phone-tuned values.

`@repo/ui`'s Button now names design system tokens (`bg-accent`, `text-fg`)
instead of the old ad-hoc `primary`/`gray` palette. Colours resolve through CSS
variables, so `dark:` variants swap real values.
16 changes: 16 additions & 0 deletions .changeset/wide-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@repo/api-contracts': major
'@repo/config': major
'@repo/ui': major
'@repo/utils': major
---

Modernize the whole toolchain: Expo 57 (React 19.2, React Native 0.86), Prisma 7
with a driver adapter, Zod 4, Fastify 5, TypeScript 6, ESLint 10, and pnpm 11.

Breaking for consumers of these packages:

- `@repo/ui` now requires React >= 19 and React Native >= 0.86.
- `@repo/api-contracts` emits Zod 4 schemas; `z.string().uuid()`/`.email()`/`.url()`
are now `z.uuid()`/`z.email()`/`z.url()`.
- `@repo/config`'s ESLint preset targets ESLint 10 flat config.
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ jobs:
node-version-file: ".nvmrc"
cache: "pnpm"

# @elirobinson/tokens comes from GitHub Packages, which the runner has no
# credential for by default.
#
# `pnpm config set`, not an echo into ~/.npmrc: pnpm 11 keeps its own
# credential store and only sends tokens from there. A token written to
# ~/.npmrc is listed back in pnpm's own "authorization settings were found"
# output and still never reaches the request, which fails as a 401 saying
# no authorization header was set.
- name: Authenticate to GitHub Packages
run: pnpm config set //npm.pkg.github.com/:_authToken "$NODE_AUTH_TOKEN"
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand All @@ -43,6 +56,19 @@ jobs:
with:
node-version-file: ".nvmrc"
cache: "pnpm"
# @elirobinson/tokens comes from GitHub Packages, which the runner has no
# credential for by default.
#
# `pnpm config set`, not an echo into ~/.npmrc: pnpm 11 keeps its own
# credential store and only sends tokens from there. A token written to
# ~/.npmrc is listed back in pnpm's own "authorization settings were found"
# output and still never reaches the request, which fails as a 401 saying
# no authorization header was set.
- name: Authenticate to GitHub Packages
run: pnpm config set //npm.pkg.github.com/:_authToken "$NODE_AUTH_TOKEN"
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}

- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm --filter mobile-web exec playwright install --with-deps chromium
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ blob-report

# Claude Code (local machine settings; safe to gitignore)
.claude/settings.local.json

# Prisma 7 generates the client into the source tree
apps/api/src/generated/
13 changes: 6 additions & 7 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Metro's resolver doesn't fully support pnpm's default symlinked/nested
# node_modules/.pnpm layout (it can't resolve packages like expo-router
# whose real path lives several directories deeper than the workspace
# root). Hoisting gives a flat, npm/yarn-style node_modules that Metro
# resolves correctly, at the cost of pnpm's strict phantom-dependency
# isolation.
node-linker=hoisted
# The design system (@elirobinson/tokens) publishes to GitHub Packages.
# Auth comes from your user-level config, never from this file.
#
# Note: pnpm 11 no longer reads settings like `node-linker` from .npmrc —
# those live in pnpm-workspace.yaml now. Only registry mapping stays here.
@elirobinson:registry=https://npm.pkg.github.com/
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ web-build
coverage
pnpm-lock.yaml
CLAUDE.md

# Generated by pnpm tokens:sync — keep the generator's own formatting
packages/config/tailwind/tokens.generated.js
186 changes: 174 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,181 @@
# Agent Instructions

This is a pnpm + Turborepo monorepo: Expo Router app (`apps/mobile-web`, iOS/Android/web
from one codebase) and a Fastify + tRPC API (`apps/api`), with shared packages in `packages/*`
(`ui`, `api-contracts`, `utils`, `config`).
pnpm + Turborepo monorepo. One Expo Router app (`apps/mobile-web`) ships iOS,
Android and web from a single codebase; `apps/api` is Fastify + tRPC; shared code
lives in `packages/*` (`ui`, `api-contracts`, `utils`, `config`).

> Edit this file only. `CLAUDE.md` is a symlink to `AGENTS.md`.

**Package manager:** pnpm (`pnpm@11.25.0`) · **Node:** 24 (`.nvmrc`)

**Common commands:** `pnpm dev` · `pnpm lint` · `pnpm typecheck` · `pnpm test` ·
`pnpm build` · `pnpm tokens:sync`

---

## Tech stack

| Layer | Choice |
| ---------------- | ----------------------------------------------------------- |
| Mobile + web app | Expo SDK 57, Expo Router, React Native 0.86, React 19.2 |
| Styling | NativeWind 4 on Tailwind 3, themed by `@elirobinson/tokens` |
| API | Fastify 5 + tRPC 11 |
| Database | Prisma 7 with the `@prisma/adapter-pg` driver adapter |
| Schemas | Zod 4, shared from `packages/api-contracts` |
| Data fetching | TanStack Query 5 via `@trpc/react-query` |
| Language | TypeScript 6 (strict) |
| Unit tests | Jest 29 (`jest-expo` for anything that renders) |
| Web E2E | Playwright · **Mobile E2E** Maestro |
| Lint | ESLint 10 flat config, shared from `packages/config` |
| Releases | Changesets, Conventional Commits via commitlint + Husky |

---

## Rules

- Use `pnpm`, not `npm` or `yarn` — the repo pins `packageManager` in `package.json`.
- Shared request/response schemas belong in `packages/api-contracts` (Zod), not duplicated
client-side. Add a router procedure in `apps/api/src/router.ts` alongside any new schema.
- The client only type-imports `AppRouter` from `apps/api` — never import server runtime code
(Prisma, handlers) from `apps/mobile-web`.
- Run `pnpm lint`, `pnpm typecheck`, and `pnpm test` before considering a change done.
- Follow Conventional Commits (enforced by commitlint + Husky) — e.g. `fix(api): ...`,
`feat(ui): ...`.
- Use `pnpm`, not `npm` or `yarn` — the repo pins `packageManager`.
- Shared request/response schemas belong in `packages/api-contracts` (Zod), not
duplicated client-side. Add a router procedure in `apps/api/src/router.ts`
alongside any new schema.
- The client only type-imports `AppRouter` from `apps/api` — never import server
runtime code (Prisma, handlers) from `apps/mobile-web`, or Metro will pull the
server bundle into the app.
- Run `pnpm lint`, `pnpm typecheck` and `pnpm test` before considering a change
done. For anything touching the app shell, styling or Metro config, also run
`pnpm build` — the gates do not exercise the bundler.
- Follow Conventional Commits — e.g. `fix(api): ...`, `feat(ui): ...`.
- Add a changeset (`pnpm changeset`) for any user-facing or package-level change.
- Styling is NativeWind (Tailwind) — keep styles in className props, not StyleSheet, unless a
- Styling is NativeWind — keep styles in `className`, not `StyleSheet`, unless a
case genuinely needs it.

---

## Styling: design system tokens

Colour, spacing, radii, type and motion come from **`@elirobinson/tokens`**
(the Miltinson design system, upstream: https://github.com/EliRobinson/design-system).
Nothing in this repo restates what a token is worth.

**Do not hardcode a colour, radius, font size or duration.** Use the utility that
names the token — `bg-accent`, `text-fg-2`, `rounded-md`, `p-4`. If you need a
value that has no token, that is a design system gap worth raising upstream, not
a hex code worth inlining.

**Discover, don't document.** Never trust a token list pasted into prose — it is
wrong as of the next release. Read the generated file instead:

```bash
node -e "console.log(Object.keys(require('./packages/config/tailwind/tokens.generated.js').colors).join('\n'))"
```

### How the bridge works

React Native cannot parse `oklch()` or resolve `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:

```
@elirobinson/tokens (palettes.css + tokens.css + mobile.css)
-> packages/config/tailwind/sync-tokens.mjs
-> tokens.generated.js (Tailwind theme: scales, and colours as var names)
-> the preset's addBase emits :root and .dark:root variable blocks
```

- **Run `pnpm tokens:sync` after bumping `@elirobinson/tokens`.** Nothing else
needs hand-editing; both generated files carry a do-not-edit banner.
- 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, not the desktop 6px.
It changes no colour by design, so every contrast ratio the system asserts
still holds.
- Colours resolve through `var(--ds-*)` rather than literal hex, which is what
makes one class (`bg-bg`) pick up the dark value automatically. `darkMode` is
`'class'` because Expo Router sets the colour scheme programmatically, which
NativeWind's default media-query dark mode cannot follow. `_layout.tsx` calls
`colorScheme.set('system')` — with `'class'`, nothing follows the OS on its own.
- **The variables are injected via `addBase`, never an `@import`, and that is
load-bearing on native.** NativeWind only treats `.dark:root` as a dark
root-variable block _after_ it has read the `@cssInterop set darkMode class dark`
at-rule that `nativewind/preset` emits — the order is strict, and variables
parsed before it silently keep only their light half. An `@import` cannot
satisfy that: postcss-import only inlines imports at the top of the file, which
is necessarily before that at-rule, and an import moved to the bottom is dropped
outright. Both failures are silent, and both look correct on web.
- Touch targets use `min-h-target` (the design system's `--target`, 44px). Padding
alone renders a 40pt control, under both the system's contract and Apple's HIG.
- `sync-tokens.mjs` **fails** if a colour token does not convert, so a new token
shape breaks the sync loudly instead of silently dropping a colour.

Installing the tokens package needs GitHub Packages auth — see
[Toolchain constraints](#toolchain-constraints).

---

## Layout

```
apps/
mobile-web/ Expo Router app — iOS, Android, web
app/ file-based routes; +html.tsx wraps the web build only
global.css imports the generated token vars, then Tailwind
e2e/ Playwright (web) and Maestro (device) suites
api/
src/router.ts tRPC procedures — add one per new contract
src/context.ts Prisma client + per-request context; wire auth here
prisma/ schema; prisma.config.ts sits at the app root
src/generated/ Prisma 7 output — gitignored, never edited
packages/
ui/ shared NativeWind components
api-contracts/ Zod schemas shared by client and server
config/ eslint, tailwind (+ token bridge), tsconfig bases
utils/ framework-free helpers
```

---

## Toolchain constraints

These are pinned deliberately. Each one was tried at a newer version and blocked
by something real — check the blocker still holds before bumping.

- **TypeScript stays on 6.x.** `ts-jest` peers `typescript <7` and
`typescript-eslint` peers `<6.1.0`. TS 7 needs both to move first.
- **Jest stays on 29 across every package.** `jest-expo@57` requires it, and the
hoisted layout means one jest major serves the whole workspace.
- **Tailwind stays on 3.x.** 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.
- **`packages/ui` pins `react` and `react-native` to the app's exact versions.**
A loose `*` peer let pnpm install a second React Native copy, and NativeWind's
`className` type augmentation then landed on the wrong module — the symptom is
`Property 'className' does not exist on type ...PressableProps`.
- **Each tsconfig names its test types explicitly** (`"types": ["jest", ...]`).
TypeScript 6 does not auto-discover them in this layout.

### pnpm 11 gotchas

- **`.npmrc` no longer configures pnpm.** Settings live in
`pnpm-workspace.yaml` — `nodeLinker: hoisted` in particular. Metro cannot
resolve pnpm's default symlinked layout, so losing that setting breaks
`expo start` while the type and test gates stay green.
- **Build scripts need approval.** `allowBuilds` in `pnpm-workspace.yaml` lists
the five dependencies that compile native binaries or Prisma engines.
- **`@elirobinson/tokens` comes from GitHub Packages.** The scope mapping is in
`.npmrc`; the token is user-level and never committed. pnpm 11 reads its
credentials from its own store (`~/Library/Preferences/pnpm/auth.ini` on
macOS) in preference to `~/.npmrc` — if an install 401s while `npm view`
works, that store holds a stale token.

### Prisma 7 shape

Prisma 7 removed `url` from the schema's datasource block. The CLI reads the
connection string from `apps/api/prisma.config.ts`; the runtime client gets it
from a `@prisma/adapter-pg` adapter constructed in `src/context.ts`. The
generator is `prisma-client` (not `prisma-client-js`) and emits into
`src/generated/prisma`, which is gitignored.

### Testing note

React Native Testing Library 14 made `render` and every `fireEvent` helper
**async** — always `await` them. `@testing-library/react-native/extend-expect`
no longer exists; the matchers register themselves.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type-safe API. Use this as a GitHub template for new projects.
| Monorepo | pnpm workspaces + Turborepo |
| Mobile + Web | Expo Router (React Native Web) — one app, 3 targets |
| Styling | NativeWind (Tailwind for RN + web) |
| Design tokens | `@elirobinson/tokens` — colour, space, radius, type |
| State (server) | TanStack Query via tRPC |
| API | Fastify + tRPC |
| Database | Prisma + Postgres |
Expand Down Expand Up @@ -49,6 +50,15 @@ Ownership to copy when you fork:

## Getting started

Requires **Node 24** (see `.nvmrc`) and **pnpm 11**.

The design system package comes from GitHub Packages, so you need a personal
access token with `read:packages` in your user-level `~/.npmrc`:

```
//npm.pkg.github.com/:_authToken=<your token>
```

```bash
pnpm install
cp apps/api/.env.example apps/api/.env # set DATABASE_URL
Expand All @@ -68,8 +78,16 @@ pnpm test # Jest unit + component tests
pnpm test:e2e:web # Playwright, against the web build
pnpm --filter mobile-web test:e2e:mobile # Maestro, needs a simulator/device
pnpm format # Prettier write
pnpm tokens:sync # regenerate the Tailwind theme from @elirobinson/tokens
```

## Styling

Colour, spacing, radii and type come from `@elirobinson/tokens`. Use the utility
that names the token (`bg-accent`, `text-fg-2`, `rounded-md`) rather than a
literal value, and run `pnpm tokens:sync` after bumping that package. See
[AGENTS.md](AGENTS.md#styling-design-system-tokens) for how the bridge works.

## Versioning changes (Changesets)

After any change worth noting, run:
Expand Down
25 changes: 14 additions & 11 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,24 @@
"postinstall": "prisma generate"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@prisma/client": "^5.18.0",
"@fastify/cors": "^11.3.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@repo/api-contracts": "workspace:*",
"@trpc/server": "^11.0.0",
"fastify": "^4.28.1",
"zod": "^3.23.8"
"@trpc/server": "^11.18.0",
"fastify": "^5.12.3",
"pg": "^8.23.0",
"zod": "^4.5.4"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"@types/jest": "^29.5.14",
"@types/node": "^26.4.1",
"@types/pg": "^8.23.1",
"jest": "^29.7.0",
"prisma": "^5.18.0",
"ts-jest": "^29.2.4",
"tsx": "^4.16.5",
"typescript": "^5.5.4"
"prisma": "^7.10.0",
"ts-jest": "^29.4.12",
"tsx": "^4.23.13",
"typescript": "^6.0.3"
}
}
11 changes: 11 additions & 0 deletions apps/api/prisma.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'prisma/config';

// Prisma 7 reads CLI configuration from here instead of the schema file.
// Only the CLI (migrate, studio, db push) uses this connection string;
// the runtime client gets its connection from the adapter in src/context.ts.
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: process.env.DATABASE_URL,
},
});
Loading