Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
49cbcc0
chore(tokens): render the design tokens as a Tailwind v4 @theme block
acharlop Sep 9, 2026
3e94342
chore(mobile): migrate to NativeWind 5 and Tailwind 4
acharlop Sep 9, 2026
dba4e33
test(mobile): assert resolved styles rather than className strings
acharlop Sep 9, 2026
d0bfe22
chore(website): consume the shared token theme, bump Tailwind to 4.3.3
acharlop Sep 9, 2026
516c490
chore(deps): bump React Native to 0.86.3 and the test tooling to jest…
acharlop Sep 9, 2026
748e248
test(mobile): scope the slow-suite timeout, stop the StatusBar spy re…
acharlop Sep 9, 2026
7c7709c
chore(mobile): finish the NativeWind 5 migration review items
acharlop Sep 10, 2026
ed0dc6e
test(mobile): UI snapshots recorded pre-upgrade, replayed against RN …
acharlop Sep 10, 2026
04480c3
docs(mobile): record the measured line-height options behind leading-6
acharlop Sep 10, 2026
3b0cdb1
fix(mobile): validate ui-diff labels; record why two bot findings stand
acharlop Sep 10, 2026
b38e3ac
fix(mobile): complete the upgrade screenshot capture flow
acharlop Sep 10, 2026
4971e36
merge PR295 native test toolchain into mobile upgrade
acharlop Sep 10, 2026
53d1b5a
merge PR295 follow-up flow fixes
acharlop Sep 10, 2026
ad9b090
test(mobile): refresh UI snapshot regression baselines
acharlop Sep 11, 2026
aa16044
fix(mobile): style landing safe area container
acharlop Sep 11, 2026
f724081
test(mobile): cover persisted theme selections on device
acharlop Sep 11, 2026
d804fc3
test(mobile): settle the entrance before capturing the home screen
acharlop Sep 11, 2026
2f3c272
Merge branch 'codex/fix-mobile-pr295' into codex/fix-mobile-pr297
acharlop Sep 11, 2026
b0b22eb
Merge origin/main into chore/nativewind-5-tailwind-4 (post-#295 squash)
acharlop Sep 11, 2026
de14d11
fix(mobile): style the unknown-Deck safe area, drop react-native-css-…
acharlop Sep 11, 2026
e7fc96f
fix(mobile): give the colour-scheme store a server snapshot
acharlop Sep 11, 2026
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
1 change: 1 addition & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"**/.turbo/**",
"pnpm-lock.yaml",
"**/drizzle/**",
"packages/tokens/theme.css",
"apps/website/**",
"**/routeTree.gen.ts"
]
Expand Down
10 changes: 6 additions & 4 deletions apps/mobile/.maestro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pnpm -F mobile e2e:android # com.whocards.mobile
- `language-persist.yaml` — a chosen language survives an app relaunch (the
`language-store` persistence, ticket 0009). Uses `?q=1` so the same card renders
before/after, making the switch observable.
- `theme-persist.yaml` — Dark, Light, and System selections each survive a
relaunch. Captures the home screen after each selection for native color checks.
- `rtl-alignment.yaml` — opens a card, screenshots the default (English, LTR) and the
Hebrew (RTL) card to verify right-alignment. Screenshots land in `.maestro/artifacts/`.
- `language-modal-inset.yaml` — opens the language sheet and screenshots it so the
Expand Down Expand Up @@ -160,10 +162,10 @@ optional, and its absence is not an error.
Capture 03 reveals the player controls before exiting to Settings. Capture 04
waits for the Hebrew question to render before waiting for the controls to hide.

This is the on-device half of upgrade evidence. The JS half is
`src/__tests__/ui-snapshot-*.test.tsx`, whose baselines are recorded on the
pre-upgrade toolchain and replayed unchanged after it; those can't see the native
side at all, which is what this makes up for.
This is the on-device half of upgrade evidence. The JS render snapshots in
`src/__tests__/ui-snapshot-*.test.tsx` use the compiled NativeWind 5 stylesheet.
They are regression baselines for this toolchain, not a pixel comparison with
the older toolchain. Native screenshots provide that comparison.

## Prerequisites (one-time)

Expand Down
40 changes: 40 additions & 0 deletions apps/mobile/.maestro/theme-persist.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Theme overrides survive a relaunch, and the player can return to System.
# Screenshots also expose whether native styles repaint when the setting changes.
appId: ${APP_ID}
tags:
- ios
- android
---
- launchApp:
clearState: true
- tapOn: 'Settings'
- tapOn: 'Theme: System'
- tapOn: 'Theme: Dark'
- assertVisible: 'Theme: Dark'
- tapOn: 'close'
- takeScreenshot: .maestro/artifacts/theme-dark

- stopApp
- launchApp
- tapOn: 'Settings'
- assertVisible: 'Theme: Dark'
- tapOn: 'Theme: Dark'
- tapOn: 'Theme: Light'
- assertVisible: 'Theme: Light'
- tapOn: 'close'
- takeScreenshot: .maestro/artifacts/theme-light

- stopApp
- launchApp
- tapOn: 'Settings'
- assertVisible: 'Theme: Light'
- tapOn: 'Theme: Light'
- tapOn: 'Theme: System'
- assertVisible: 'Theme: System'
- tapOn: 'close'
- takeScreenshot: .maestro/artifacts/theme-system

- stopApp
- launchApp
- tapOn: 'Settings'
- assertVisible: 'Theme: System'
23 changes: 22 additions & 1 deletion apps/mobile/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
const path = require('node:path')

module.exports = function (api) {
// Calling api.env() registers env-keyed caching (so we must NOT also call
// api.cache(true), which would throw "Caching has already been configured").
const isTest = api.env('test')
return {
presets: [['babel-preset-expo', {jsxImportSource: 'nativewind'}], 'nativewind/babel'],
presets: ['babel-preset-expo'],
// In Metro builds, babel-preset-expo already injects babel-plugin-react-compiler
// (it reads app.json experiments.reactCompiler via the Metro caller), so adding it
// here too would run the compiler twice. Jest has no Metro caller, so the preset
// can't inject it — add it explicitly for the test transform only.
plugins: isTest ? ['babel-plugin-react-compiler'] : [],
// NativeWind v5 makes `className` work by swapping `react-native` for
// `react-native-css/components`. On device the Metro resolver does that swap
// (see metro.config.js), and it does it broadly: for every origin module it
// resolves, node_modules included, and for `react-native-safe-area-context`
// and deep `react-native/Libraries/**` imports as well as bare
// `react-native`. Only react-native-css's own files and react-native's index
// itself are exempt.
// The safe-area adapter wraps SafeAreaProvider to expose CSS inset variables;
// it re-exports SafeAreaView without adding className support. Pass native
// styles to SafeAreaView, and keep its Jest mock equally unstyled.
//
// Jest has no Metro, so there the rewrite has to happen in Babel, which is a
// narrower tool: it only rewrites `react-native` and `react-native/<Module>`
// specifiers, never safe-area-context. It is also unscoped by default, and
// rewriting react-native's own internals deadlocks the jest-expo Image mock
// (react-native's LogBox imports Image, which would resolve back into the
// half-initialised mock). So the preset is limited to src/ — deliberately
// less than Metro rewrites, but enough for the `className` props under test.
overrides: isTest ? [{test: path.join(__dirname, 'src'), presets: ['nativewind/babel']}] : [],
}
}
3 changes: 2 additions & 1 deletion apps/mobile/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ module.exports = {
'|@expo(-.*)?/?' +
'|@unimodules/?' +
'|nativewind' +
'|react-native-css-interop' +
'|react-native-css' +
'|react-native-gesture-handler' +
'|react-native-safe-area-context' +
'|react-native-screens' +
Expand All @@ -27,6 +27,7 @@ module.exports = {
'/node_modules/react-native-reanimated/plugin/',
'/node_modules/@react-native/babel-preset/',
],
globalSetup: '<rootDir>/jest.global-setup.js',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
// Resolve the @/* path alias declared in tsconfig.json.
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/jest.global-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const fs = require('node:fs')
const path = require('node:path')

/**
* Compiles the app's real src/global.css once, before the suite runs, and caches
* the result for jest.setup.ts to inject into every test.
*
* The entry point is jest.global.css rather than src/global.css itself: that
* wrapper is the shipped entry plus one extra `@source` for src/__tests__,
* whose assertion-only class names the shipped stylesheet deliberately excludes.
*
* This is the exact pipeline Metro runs on device (react-native-css's Metro
* transformer: PostCSS/Tailwind → `compile()` → `StyleCollection.inject()`), so
* the styles the tests assert on are the styles the app ships — including the
* `@theme` block generated from @whocards/tokens. A test that renders a
* component and reads back a resolved colour is therefore also a check that the
* whole CSS pipeline still works, which is the part of a NativeWind upgrade
* that CI otherwise can't see.
*/
const STYLESHEET_PATH = path.join(__dirname, 'node_modules/.cache/whocards/global-css.json')

module.exports = async () => {
// Required lazily: these are build-time-only deps, and globalSetup runs
// outside the jest module registry.
const postcss = require('postcss')
const tailwindcss = require('@tailwindcss/postcss')
const {compile} = require('react-native-css/compiler')

const input = path.join(__dirname, 'jest.global.css')
const {css} = await postcss([tailwindcss()]).process(fs.readFileSync(input, 'utf8'), {
from: input,
})
const stylesheet = compile(css, {filename: input, projectRoot: __dirname}).stylesheet()

fs.mkdirSync(path.dirname(STYLESHEET_PATH), {recursive: true})
fs.writeFileSync(STYLESHEET_PATH, JSON.stringify(stylesheet))
}

module.exports.STYLESHEET_PATH = STYLESHEET_PATH
11 changes: 11 additions & 0 deletions apps/mobile/jest.global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Test-only Tailwind entry point: the stylesheet the app ships, plus the class
* names that only ever appear in `src/__tests__`.
*
* `src/global.css` deliberately does not scan the test suite (see the `@source
* not` there) so ~40 assertion-only utilities stay out of the shipped bundle.
* The tests still have to resolve them, so jest.global-setup.js compiles this
* file instead — same imports, same theme, same compiler, one extra `@source`.
*/
@import './src/global.css';
@source './src/__tests__/**/*.{ts,tsx}';
34 changes: 34 additions & 0 deletions apps/mobile/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,37 @@
// Gesture handler needs its jest shims loaded before any component under test
// that imports react-native-gesture-handler.
import 'react-native-gesture-handler/jestSetup'

import {readFileSync} from 'node:fs'
import {act} from '@testing-library/react-native'
import {StyleCollection} from 'react-native-css/native'

import {setColorScheme} from '@/lib/color-scheme'

// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- globalSetup is CJS-only, so its one exported constant has to be require()d and re-typed here.
const {STYLESHEET_PATH} = require('./jest.global-setup.js') as {STYLESHEET_PATH: string}

/**
* Load the compiled global.css (see jest.global-setup.js) into the same style
* registry the Metro bundler writes into on device, so `className` resolves in
* tests exactly as it does in the app. Without this every className is inert
* and components render unstyled — which is precisely the failure mode the
* styling tests are there to catch.
*/
const stylesheet: unknown = JSON.parse(readFileSync(STYLESHEET_PATH, 'utf8'))

beforeEach(() => {
StyleCollection.styles.clear()
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON round-trip of the compiler's own output.
StyleCollection.inject(stylesheet as Parameters<typeof StyleCollection.inject>[0])
})

/**
* The resolved colour scheme is a process-wide observable, and the Theme setting
* that drives it is a module-level variable — so a test that renders in Light
* would otherwise hand Light to whatever runs next. Reset it here rather than in
* each suite that touches it: the leak is silent (a `dark:` class simply
* resolves to the wrong colour) and it can strike any suite, not only the ones
* that opted into a reset.
*/
afterEach(async () => await act(() => setColorScheme('system')))
8 changes: 6 additions & 2 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Learn more: https://docs.expo.dev/guides/monorepo/
const {getDefaultConfig} = require('expo/metro-config')
const {withNativeWind} = require('nativewind/metro')
const {withNativewind} = require('nativewind/metro')
const path = require('path')

const projectRoot = __dirname
Expand All @@ -20,4 +20,8 @@ config.resolver.nodeModulesPaths = [
// 3. Honour the "exports" field so @whocards/* resolve to their src entry points.
config.resolver.unstable_enablePackageExports = true

module.exports = withNativeWind(config, {input: './src/global.css'})
// 4. NativeWind v5: registers the CSS transformer and the resolver that swaps
// `react-native` for `react-native-css/components`, which is what gives every
// primitive its `className` prop. There is no `input` option any more —
// src/global.css is imported as a normal module from src/app/_layout.tsx.
module.exports = withNativewind(config)
9 changes: 8 additions & 1 deletion apps/mobile/nativewind-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/// <reference types="nativewind/types" />
/// <reference types="react-native-css/types" />

/*
* NativeWind v5 dropped the ambient `*.css` module declaration that v4's
* generated `nativewind-env.d.ts` carried, and TypeScript errors (TS2882) on a
* side-effect import it can't resolve. `src/global.css` is consumed by Metro
* (and, in tests, by jest.global-setup.js) — it has no runtime exports, so an
* empty module is the accurate type.
*/
declare module '*.css' {}
10 changes: 7 additions & 3 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,34 @@
"expo-store-review": "~57.0.2",
"expo-system-ui": "~57.0.3",
"expo-updates": "~57.0.21",
"nativewind": "^4.1.23",
"nativewind": "5.0.0-preview.4",
"posthog-react-native": "^4.67.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-native": "0.86.3",
"react-native-css-interop": "0.2.6",
"react-native-css": "^3.0.7",
"react-native-gesture-handler": "~3.2.1",
"react-native-reanimated": "4.6.0",
"react-native-safe-area-context": "~5.9.1",
"react-native-screens": "4.27.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.12.1",
"tailwindcss": "^3.4.17",
"zod": "catalog:"
},
"//lightningcss": "A peer dependency of react-native-css, not something this app imports — it is listed (and pinned) only so the peer resolves. The exact version is in lockstep with the '@expo/metro-config>lightningcss' override in pnpm-workspace.yaml; that comment carries the reason for the pin and the condition for removing both. Bump the two together or not at all.",
"devDependencies": {
"@babel/plugin-transform-react-jsx": "^7.29.7",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react-native": "^14.0.1",
"@types/jest": "^30.0.0",
"@types/react": "~19.2.18",
"babel-plugin-react-compiler": "1.0.0",
"babel-preset-expo": "~57.0.10",
"jest": "^30.5.1",
"jest-expo": "~57.0.5",
"lightningcss": "1.30.1",
"postcss": "^8.5.28",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3"
}
}
10 changes: 10 additions & 0 deletions apps/mobile/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Tailwind v4 runs as a PostCSS plugin. Expo's Metro CSS pipeline picks this
* file up when it transforms src/global.css, and NativeWind v5 compiles that
* output into the native stylesheet (see metro.config.js).
*/
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
Loading
Loading