diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d147ee1c..b3062ab6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -21,6 +21,7 @@ "**/.turbo/**", "pnpm-lock.yaml", "**/drizzle/**", + "packages/tokens/theme.css", "apps/website/**", "**/routeTree.gen.ts" ] diff --git a/apps/mobile/.maestro/README.md b/apps/mobile/.maestro/README.md index e35475d0..36a0a16a 100644 --- a/apps/mobile/.maestro/README.md +++ b/apps/mobile/.maestro/README.md @@ -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 @@ -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) diff --git a/apps/mobile/.maestro/theme-persist.yaml b/apps/mobile/.maestro/theme-persist.yaml new file mode 100644 index 00000000..ff77a1f8 --- /dev/null +++ b/apps/mobile/.maestro/theme-persist.yaml @@ -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' diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js index 65407051..459d9051 100644 --- a/apps/mobile/babel.config.js +++ b/apps/mobile/babel.config.js @@ -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/` + // 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']}] : [], } } diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 295e0874..7f555421 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -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' + @@ -27,6 +27,7 @@ module.exports = { '/node_modules/react-native-reanimated/plugin/', '/node_modules/@react-native/babel-preset/', ], + globalSetup: '/jest.global-setup.js', setupFilesAfterEnv: ['/jest.setup.ts'], moduleNameMapper: { // Resolve the @/* path alias declared in tsconfig.json. diff --git a/apps/mobile/jest.global-setup.js b/apps/mobile/jest.global-setup.js new file mode 100644 index 00000000..0dc335af --- /dev/null +++ b/apps/mobile/jest.global-setup.js @@ -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 diff --git a/apps/mobile/jest.global.css b/apps/mobile/jest.global.css new file mode 100644 index 00000000..2294bb2d --- /dev/null +++ b/apps/mobile/jest.global.css @@ -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}'; diff --git a/apps/mobile/jest.setup.ts b/apps/mobile/jest.setup.ts index 91dd4b42..a0546d45 100644 --- a/apps/mobile/jest.setup.ts +++ b/apps/mobile/jest.setup.ts @@ -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[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'))) diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 2841d8e9..6a6d7113 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -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 @@ -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) diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts index 62e8864c..2ef5b750 100644 --- a/apps/mobile/nativewind-env.d.ts +++ b/apps/mobile/nativewind-env.d.ts @@ -1,3 +1,10 @@ -/// +/// +/* + * 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' {} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1d5547df..f790d360 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -54,23 +54,24 @@ "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", @@ -78,6 +79,9 @@ "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" } } diff --git a/apps/mobile/postcss.config.mjs b/apps/mobile/postcss.config.mjs new file mode 100644 index 00000000..579a4332 --- /dev/null +++ b/apps/mobile/postcss.config.mjs @@ -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': {}, + }, +} diff --git a/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-screens.test.tsx.snap b/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-screens.test.tsx.snap index 7d38f366..d1d8da33 100644 --- a/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-screens.test.tsx.snap +++ b/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-screens.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Library screen renders the Library unchanged 1`] = ` +exports[`Library screen renders the Library screen 1`] = ` | flex: 1 @@ -20,9 +20,18 @@ exports[`Library screen renders the Library unchanged 1`] = ` | right: 0 | top: 0 - ~ className: "flex-1 items-center justify-between px-8 pb-8 pt-16" + | alignItems: "center" + | flex: 1 + | justifyContent: "space-between" + | paddingBottom: 28 + | paddingHorizontal: 28 + | paddingTop: 56 - ~ className: "flex-1 items-center justify-center" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "center" | transform: [{"translateY":0}] @@ -30,23 +39,40 @@ exports[`Library screen renders the Library unchanged 1`] = ` | height: 53 | width: 280 - ~ className: "text-darker/80 dark:text-white/80 mt-7 text-center font-sans text-xl font-semibold leading-8" + | color: "#111516cc" + | fontFamily: "golos-text" + | fontSize: 17.5 + | fontWeight: 600 + | lineHeight: 26.25 + | marginTop: 24.5 | opacity: 1 + | textAlign: "center" "Change your world," "\\n" "one conversation at a time." - ~ className: "w-full items-center gap-5" + | alignItems: "center" + | gap: 17.5 | opacity: 1 + | width: "100%" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "66" " cards · " "14" " languages" - - ~ className: "active:bg-yellow-500 w-full flex-row items-center justify-center rounded-full bg-yellow-400 py-4" + + | alignItems: "center" + | backgroundColor: "#f9d75f" + | borderRadius: 9007199254740990 + | flexDirection: "row" + | justifyContent: "center" + | paddingBlock: 14 | transform: [{"scale":1}] + | width: "100%" | color: "#111516" | fontFamily: "ionicons" @@ -56,11 +82,22 @@ exports[`Library screen renders the Library unchanged 1`] = ` | marginRight: 8 "" - ~ className: "text-darker font-sans text-base font-bold" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 14 + | fontWeight: 700 + | lineHeight: 21 "Play" - - ~ className: "group active:bg-gray bg-gray w-full flex-row items-center justify-center gap-2 rounded-full py-4" + + | alignItems: "center" + | backgroundColor: "#65636e" + | borderRadius: 9007199254740990 + | flexDirection: "row" + | gap: 7 + | justifyContent: "center" + | paddingBlock: 14 | transform: [{"scale":1}] + | width: "100%" | color: "#f5f5f5" | fontFamily: "ionicons" @@ -69,11 +106,15 @@ exports[`Library screen renders the Library unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "group-active:text-yellow-500 font-sans text-base font-bold text-white" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 14 + | fontWeight: 700 + | lineHeight: 21 "Settings" `; -exports[`Library screen renders the Library unchanged in the Dark Theme Display setting 1`] = ` +exports[`Library screen renders the Library screen in the Dark Theme Display setting 1`] = ` | flex: 1 @@ -93,9 +134,18 @@ exports[`Library screen renders the Library unchanged in the Dark Theme Display | right: 0 | top: 0 - ~ className: "flex-1 items-center justify-between px-8 pb-8 pt-16" + | alignItems: "center" + | flex: 1 + | justifyContent: "space-between" + | paddingBottom: 28 + | paddingHorizontal: 28 + | paddingTop: 56 - ~ className: "flex-1 items-center justify-center" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "center" | transform: [{"translateY":0}] @@ -103,23 +153,40 @@ exports[`Library screen renders the Library unchanged in the Dark Theme Display | height: 53 | width: 280 - ~ className: "text-darker/80 dark:text-white/80 mt-7 text-center font-sans text-xl font-semibold leading-8" + | color: "#f5f5f5cc" + | fontFamily: "golos-text" + | fontSize: 17.5 + | fontWeight: 600 + | lineHeight: 26.25 + | marginTop: 24.5 | opacity: 1 + | textAlign: "center" "Change your world," "\\n" "one conversation at a time." - ~ className: "w-full items-center gap-5" + | alignItems: "center" + | gap: 17.5 | opacity: 1 + | width: "100%" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "66" " cards · " "14" " languages" - - ~ className: "active:bg-yellow-500 w-full flex-row items-center justify-center rounded-full bg-yellow-400 py-4" + + | alignItems: "center" + | backgroundColor: "#f9d75f" + | borderRadius: 9007199254740990 + | flexDirection: "row" + | justifyContent: "center" + | paddingBlock: 14 | transform: [{"scale":1}] + | width: "100%" | color: "#111516" | fontFamily: "ionicons" @@ -129,11 +196,22 @@ exports[`Library screen renders the Library unchanged in the Dark Theme Display | marginRight: 8 "" - ~ className: "text-darker font-sans text-base font-bold" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 14 + | fontWeight: 700 + | lineHeight: 21 "Play" - - ~ className: "group active:bg-gray bg-gray w-full flex-row items-center justify-center gap-2 rounded-full py-4" + + | alignItems: "center" + | backgroundColor: "#65636e" + | borderRadius: 9007199254740990 + | flexDirection: "row" + | gap: 7 + | justifyContent: "center" + | paddingBlock: 14 | transform: [{"scale":1}] + | width: "100%" | color: "#f5f5f5" | fontFamily: "ionicons" @@ -142,11 +220,15 @@ exports[`Library screen renders the Library unchanged in the Dark Theme Display | fontWeight: "normal" "" - ~ className: "group-active:text-yellow-500 font-sans text-base font-bold text-white" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 14 + | fontWeight: 700 + | lineHeight: 21 "Settings" `; -exports[`Play screen renders a Question unchanged 1`] = ` +exports[`Play screen renders a Question 1`] = ` | flex: 1 @@ -166,18 +248,26 @@ exports[`Play screen renders a Question unchanged 1`] = ` | right: 0 | top: 0 - ~ className: "flex-1" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 - ~ className: "flex-1 px-8" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 | paddingBottom: 0 + | paddingInline: 28 | paddingTop: 0 - ~ className: "flex-1 justify-center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "center" | opacity: 1 | transform: [{"translateX":0}] - ~ className: "text-darker dark:text-white" + | color: "#111516" | fontFamily: "golos-text" | fontSize: 81 | lineHeight: 93.15 @@ -185,13 +275,27 @@ exports[`Play screen renders a Question unchanged 1`] = ` | writingDirection: "ltr" "What is the most interesting thing you have learned recently?\\n\\n(about yourself or in general)" - ~ className: "absolute inset-x-0 bottom-0" + | bottom: 0 + | insetInline: 0 + | position: "absolute" | transform: [{"translateY":0}] - ~ className: "border-darker/10 bg-white/90 flex-row items-stretch border-t px-4 pt-2 dark:border-white/10 dark:bg-darker/80" + | alignItems: "stretch" + | backgroundColor: "#f5f5f5e5" + | borderColor: "#1115161a" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | flexDirection: "row" | paddingBottom: 10 + | paddingInline: 14 + | paddingTop: 7 - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -201,10 +305,18 @@ exports[`Play screen renders a Question unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Back" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -214,10 +326,18 @@ exports[`Play screen renders a Question unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Exit" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -227,10 +347,18 @@ exports[`Play screen renders a Question unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Share" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -240,11 +368,14 @@ exports[`Play screen renders a Question unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Next" `; -exports[`Play screen renders a Question unchanged in the Dark Theme Display setting 1`] = ` +exports[`Play screen renders a Question in the Dark Theme Display setting 1`] = ` | flex: 1 @@ -264,18 +395,26 @@ exports[`Play screen renders a Question unchanged in the Dark Theme Display sett | right: 0 | top: 0 - ~ className: "flex-1" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 - ~ className: "flex-1 px-8" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 | paddingBottom: 0 + | paddingInline: 28 | paddingTop: 0 - ~ className: "flex-1 justify-center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "center" | opacity: 1 | transform: [{"translateX":0}] - ~ className: "text-darker dark:text-white" + | color: "#f5f5f5" | fontFamily: "golos-text" | fontSize: 81 | lineHeight: 93.15 @@ -283,13 +422,27 @@ exports[`Play screen renders a Question unchanged in the Dark Theme Display sett | writingDirection: "ltr" "What is the most interesting thing you have learned recently?\\n\\n(about yourself or in general)" - ~ className: "absolute inset-x-0 bottom-0" + | bottom: 0 + | insetInline: 0 + | position: "absolute" | transform: [{"translateY":0}] - ~ className: "border-darker/10 bg-white/90 flex-row items-stretch border-t px-4 pt-2 dark:border-white/10 dark:bg-darker/80" + | alignItems: "stretch" + | backgroundColor: "#111516cc" + | borderColor: "#f5f5f51a" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | flexDirection: "row" | paddingBottom: 10 + | paddingInline: 14 + | paddingTop: 7 - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#f5f5f5" @@ -299,10 +452,18 @@ exports[`Play screen renders a Question unchanged in the Dark Theme Display sett | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Back" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#f5f5f5" @@ -312,10 +473,18 @@ exports[`Play screen renders a Question unchanged in the Dark Theme Display sett | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Exit" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#f5f5f5" @@ -325,10 +494,18 @@ exports[`Play screen renders a Question unchanged in the Dark Theme Display sett | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Share" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#f5f5f5" @@ -338,11 +515,14 @@ exports[`Play screen renders a Question unchanged in the Dark Theme Display sett | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Next" `; -exports[`Play screen renders the Pick a Card deck unchanged before a deal 1`] = ` +exports[`Play screen renders the Pick a Card deck before a deal 1`] = ` | flex: 1 @@ -362,29 +542,58 @@ exports[`Play screen renders the Pick a Card deck unchanged before a deal 1`] = | right: 0 | top: 0 - ~ className: "flex-1" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 - ~ className: "flex-1 px-8" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 | paddingBottom: 0 + | paddingInline: 28 | paddingTop: 0 - ~ className: "flex-1 items-center justify-center" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "center" - ~ className: "items-center" + | alignItems: "center" | height: 953 | width: 686 - ~ className: "bg-dark/80 absolute inset-0 rounded-[20px] border border-white/10" + | backgroundColor: "#262432cc" + | borderColor: "#f5f5f51a" + | borderRadius: 20 + | borderStyle: "solid" + | borderWidth: 1 + | inset: 0 + | position: "absolute" | transform: [{"rotate":"-3deg"},{"translateY":10}] - ~ className: "bg-dark/80 absolute inset-0 rounded-[20px] border border-white/10" + | backgroundColor: "#262432cc" + | borderColor: "#f5f5f51a" + | borderRadius: 20 + | borderStyle: "solid" + | borderWidth: 1 + | inset: 0 + | position: "absolute" | transform: [{"rotate":"2deg"},{"translateY":5}] - ~ className: "absolute inset-0" + | inset: 0 + | position: "absolute" | transform: [{"scale":1}] - ~ className: "bg-darkest absolute inset-0 overflow-hidden rounded-[20px] border border-white/10" + | backgroundColor: "#08001a" + | borderColor: "#f5f5f51a" + | borderRadius: 20 + | borderStyle: "solid" + | borderWidth: 1 + | inset: 0 + | overflow: "hidden" + | position: "absolute" | bottom: 0 | left: 0 @@ -392,30 +601,51 @@ exports[`Play screen renders the Pick a Card deck unchanged before a deal 1`] = | right: 0 | top: 0 - ~ className: "absolute bottom-5 right-5 font-title text-yellow-400" + | bottom: 17.5 + | color: "#f9d75f" + | fontFamily: "aptly" | fontSize: 26 + | position: "absolute" + | right: 17.5 "WHO" - ~ className: "text-primary-dark" + | color: "#c058d2" "?" - ~ className: "text-white" + | color: "#f5f5f5" "CARDS" - ~ className: "justify-center" | height: 40 + | justifyContent: "center" | marginTop: 28 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Tap to pick a card" - ~ className: "absolute inset-x-0 bottom-0" + | bottom: 0 + | insetInline: 0 + | position: "absolute" | transform: [{"translateY":0}] - ~ className: "border-darker/10 bg-white/90 flex-row items-stretch border-t px-4 pt-2 dark:border-white/10 dark:bg-darker/80" + | alignItems: "stretch" + | backgroundColor: "#f5f5f5e5" + | borderColor: "#1115161a" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | flexDirection: "row" | paddingBottom: 10 + | paddingInline: 14 + | paddingTop: 7 - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -425,10 +655,18 @@ exports[`Play screen renders the Pick a Card deck unchanged before a deal 1`] = | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Back" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -438,10 +676,18 @@ exports[`Play screen renders the Pick a Card deck unchanged before a deal 1`] = | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Exit" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -451,11 +697,14 @@ exports[`Play screen renders the Pick a Card deck unchanged before a deal 1`] = | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Next" `; -exports[`Play screen renders the unknown-Deck message unchanged 1`] = ` +exports[`Play screen renders the unknown-Deck message 1`] = ` | flex: 1 @@ -475,20 +724,23 @@ exports[`Play screen renders the unknown-Deck message unchanged 1`] = ` | right: 0 | top: 0 - ~ className: "flex-1 items-center justify-center" + | alignItems: "center" + | flex: 1 + | justifyContent: "center" - ~ className: "text-darker dark:text-white font-sans" + | color: "#111516" + | fontFamily: "golos-text" "Deck not found." `; -exports[`Root layout renders the screen-transition background unchanged 1`] = ` +exports[`Root layout renders the screen-transition background 1`] = ` | flex: 1 | backgroundColor: "#efe7f7" `; -exports[`Root layout renders the screen-transition background unchanged in Dark 1`] = ` +exports[`Root layout renders the screen-transition background in Dark 1`] = ` | flex: 1 diff --git a/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-surfaces.test.tsx.snap b/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-surfaces.test.tsx.snap index 39c100fd..45bf25f0 100644 --- a/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-surfaces.test.tsx.snap +++ b/apps/mobile/src/__tests__/__snapshots__/ui-snapshot-surfaces.test.tsx.snap @@ -1,13 +1,25 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Player bar renders Prev / Exit / Share / Next unchanged 1`] = ` +exports[`Player bar renders Prev / Exit / Share / Next 1`] = ` | flex: 1 - ~ className: "border-darker/10 bg-white/90 flex-row items-stretch border-t px-4 pt-2 dark:border-white/10 dark:bg-darker/80" + | alignItems: "stretch" + | backgroundColor: "#f5f5f5e5" + | borderColor: "#1115161a" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | flexDirection: "row" | paddingBottom: 10 + | paddingInline: 14 + | paddingTop: 7 - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -17,10 +29,18 @@ exports[`Player bar renders Prev / Exit / Share / Next unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Back" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -30,10 +50,18 @@ exports[`Player bar renders Prev / Exit / Share / Next unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Exit" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -43,10 +71,18 @@ exports[`Player bar renders Prev / Exit / Share / Next unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Share" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -56,18 +92,33 @@ exports[`Player bar renders Prev / Exit / Share / Next unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Next" `; -exports[`Player bar renders without Share unchanged (the pick screen before a deal) 1`] = ` +exports[`Player bar renders without Share (the pick screen before a deal) 1`] = ` | flex: 1 - ~ className: "border-darker/10 bg-white/90 flex-row items-stretch border-t px-4 pt-2 dark:border-white/10 dark:bg-darker/80" + | alignItems: "stretch" + | backgroundColor: "#f5f5f5e5" + | borderColor: "#1115161a" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | flexDirection: "row" | paddingBottom: 10 + | paddingInline: 14 + | paddingTop: 7 - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -77,10 +128,18 @@ exports[`Player bar renders without Share unchanged (the pick screen before a de | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Back" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -90,10 +149,18 @@ exports[`Player bar renders without Share unchanged (the pick screen before a de | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Exit" - ~ className: "flex-1 items-center gap-1 py-1" + | alignItems: "center" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | gap: 3.5 + | paddingBlock: 3.5 | transform: [{"scale":1}] | color: "#111516" @@ -103,19 +170,31 @@ exports[`Player bar renders without Share unchanged (the pick screen before a de | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-xs" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | lineHeight: 14 "Next" `; -exports[`Settings pages renders the Game page unchanged 1`] = ` +exports[`Settings pages renders the Game page 1`] = ` | flex: 1 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "flex-row items-center gap-1" + | alignItems: "center" + | flexDirection: "row" + | gap: 3.5 | color: "#111516" @@ -125,54 +204,101 @@ exports[`Settings pages renders the Game page unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Choose your game" | maxHeight: 1067.2 - ~ className: "gap-1 px-5 py-4 bg-yellow-300/40" + | backgroundColor: "#ffe37e66" + | gap: 3.5 + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "flex-row items-center justify-between" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" - ~ className: "flex-row items-center gap-2" + | alignItems: "center" + | flexDirection: "row" + | gap: 7 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Classic" - ~ className: "text-accentOnLight dark:text-accentOnDark text-lg font-bold" + | color: "#9a3aac" + | fontSize: 15.75 + | fontWeight: 700 + | lineHeight: 24.5 "✓" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Swipe through the deck, one question after another." - ~ className: "gap-1 px-5 py-4 " + | gap: 3.5 + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "flex-row items-center justify-between" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" - ~ className: "flex-row items-center gap-2" + | alignItems: "center" + | flexDirection: "row" + | gap: 7 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Pick a Card" - ~ className: "rounded-full bg-yellow-400 px-2 py-0.5" + | backgroundColor: "#f9d75f" + | borderRadius: 9007199254740990 + | paddingBlock: 1.75 + | paddingInline: 7 - ~ className: "text-darker font-sans text-xs font-bold" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | lineHeight: 14 "Included in early access" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Draw each card yourself — a fresh deal for every question." `; -exports[`Settings pages renders the Language page unchanged 1`] = ` +exports[`Settings pages renders the Language page 1`] = ` | flex: 1 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "flex-row items-center gap-1" + | alignItems: "center" + | flexDirection: "row" + | gap: 3.5 | color: "#111516" @@ -182,35 +308,65 @@ exports[`Settings pages renders the Language page unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Choose your language" | maxHeight: 1067.2 - ~ className: "flex-row items-center justify-between px-5 py-4 bg-yellow-300/40" + | alignItems: "center" + | backgroundColor: "#ffe37e66" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "English" - ~ className: "text-accentOnLight dark:text-accentOnDark text-lg font-bold" + | color: "#9a3aac" + | fontSize: 15.75 + | fontWeight: 700 + | lineHeight: 24.5 "✓" - ~ className: "flex-row items-center justify-between px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Hebrew" `; -exports[`Settings pages renders the Second language page unchanged 1`] = ` +exports[`Settings pages renders the Second language page 1`] = ` | flex: 1 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "flex-row items-center gap-1" + | alignItems: "center" + | flexDirection: "row" + | gap: 3.5 | color: "#111516" @@ -220,40 +376,77 @@ exports[`Settings pages renders the Second language page unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Second language" | maxHeight: 1067.2 - ~ className: "flex-row items-center justify-between px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "None" - ~ className: "flex-row items-center justify-between px-5 py-4 bg-yellow-300/40" + | alignItems: "center" + | backgroundColor: "#ffe37e66" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Hebrew" - ~ className: "text-accentOnLight dark:text-accentOnDark text-lg font-bold" + | color: "#9a3aac" + | fontSize: 15.75 + | fontWeight: 700 + | lineHeight: 24.5 "✓" - ~ className: "flex-row items-center justify-between px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Spanish" `; -exports[`Settings pages renders the Theme page unchanged 1`] = ` +exports[`Settings pages renders the Theme page 1`] = ` | flex: 1 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "flex-row items-center gap-1" + | alignItems: "center" + | flexDirection: "row" + | gap: 3.5 | color: "#111516" @@ -263,54 +456,107 @@ exports[`Settings pages renders the Theme page unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Theme" - ~ className: "px-5 py-4" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "bg-gray-lighter dark:bg-white/10 flex-row rounded-xl p-1" + | backgroundColor: "#dcdee9" + | borderRadius: 10.5 + | flexDirection: "row" + | padding: 3.5 - ~ className: "flex-1 items-center rounded-lg py-2.5 bg-white dark:bg-dark" + | alignItems: "center" + | backgroundColor: "#f5f5f5" + | borderRadius: 7 | elevation: 1 + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | paddingBlock: 8.75 | shadowColor: "#000" | shadowOffset: {"height":1,"width":0} | shadowOpacity: 0.12 | shadowRadius: 2 - ~ className: "text-darker dark:text-white font-sans text-sm font-bold" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 12.25 + | fontWeight: 700 + | lineHeight: 17.5 "System" - ~ className: "flex-1 items-center rounded-lg py-2.5 " + | alignItems: "center" + | borderRadius: 7 + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | paddingBlock: 8.75 - ~ className: "text-darker dark:text-white font-sans text-sm " + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Light" - ~ className: "flex-1 items-center rounded-lg py-2.5 " + | alignItems: "center" + | borderRadius: 7 + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | paddingBlock: 8.75 - ~ className: "text-darker dark:text-white font-sans text-sm " + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Dark" - ~ className: "text-mutedOnLight dark:text-gray-dark mt-4 font-sans text-sm leading-5" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 + | marginTop: 14 "Matches your device by default. Presentation only — it never changes which Card is drawn or your progress." `; -exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] = ` +exports[`Settings sheet renders a risen page over the dimmed menu 1`] = ` | flex: 1 - ~ className: "flex-1 justify-end" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "flex-end" - ~ className: "absolute inset-0" + | inset: 0 + | position: "absolute" - ~ className: "bg-white dark:bg-dark rounded-t-3xl" + | backgroundColor: "#f5f5f5" + | borderTopLeftRadius: 21 + | borderTopRightRadius: 21 | paddingBottom: 24 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Settings" @@ -324,17 +570,41 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | maxHeight: 1067.2 - ~ className: "text-mutedOnLight dark:text-gray-dark px-5 pb-1 pt-4 font-sans text-xs font-bold uppercase tracking-wide" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | letterSpacing: 0.26 + | lineHeight: 14 + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 14 + | textTransform: "uppercase" "Gameplay" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Game" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "Classic" | color: "#6b6d82" @@ -344,14 +614,29 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | fontWeight: "normal" "" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Language" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "English" | color: "#6b6d82" @@ -361,14 +646,29 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | fontWeight: "normal" "" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Second language" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "None" | color: "#6b6d82" @@ -378,17 +678,32 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | fontWeight: "normal" "" - ~ className: "px-5 pb-1 pt-5" + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 17.5 - ~ className: "text-darker dark:text-white font-title text-lg" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 15.75 + | lineHeight: 24.5 "Tabletop mode" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Lay the phone flat on the table — the question mirrors on top so both sides can read it at once." - ~ className: "flex-row items-center justify-between px-5 py-3 " + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 10.5 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Two-way readable" | color: "#6b6d82" @@ -398,17 +713,42 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark border-gray-lighter dark:border-white/10 mt-2 border-t px-5 pb-1 pt-5 font-sans text-xs font-bold uppercase tracking-wide" + | borderColor: "#dcdee9" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | letterSpacing: 0.26 + | lineHeight: 14 + | marginTop: 7 + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 17.5 + | textTransform: "uppercase" "Appearance" - ~ className: "flex-row items-center justify-between px-5 py-4" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Theme" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "System" | color: "#6b6d82" @@ -431,20 +771,34 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | right: 0 | top: 0 - ~ className: "flex-1 bg-black" + | backgroundColor: "#000" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 | flex: 1 | justifyContent: "flex-end" | transform: [{"translateY":0}] - ~ className: "bg-white dark:bg-dark rounded-t-3xl" + | backgroundColor: "#f5f5f5" + | borderTopLeftRadius: 21 + | borderTopRightRadius: 21 | paddingBottom: 24 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "flex-row items-center gap-1" + | alignItems: "center" + | flexDirection: "row" + | gap: 3.5 | color: "#111516" @@ -454,62 +808,116 @@ exports[`Settings sheet renders a risen page over the dimmed menu unchanged 1`] | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Choose your game" | maxHeight: 1067.2 - ~ className: "gap-1 px-5 py-4 bg-yellow-300/40" + | backgroundColor: "#ffe37e66" + | gap: 3.5 + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "flex-row items-center justify-between" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" - ~ className: "flex-row items-center gap-2" + | alignItems: "center" + | flexDirection: "row" + | gap: 7 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Classic" - ~ className: "text-accentOnLight dark:text-accentOnDark text-lg font-bold" + | color: "#9a3aac" + | fontSize: 15.75 + | fontWeight: 700 + | lineHeight: 24.5 "✓" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Swipe through the deck, one question after another." - ~ className: "gap-1 px-5 py-4 " + | gap: 3.5 + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "flex-row items-center justify-between" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" - ~ className: "flex-row items-center gap-2" + | alignItems: "center" + | flexDirection: "row" + | gap: 7 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Pick a Card" - ~ className: "rounded-full bg-yellow-400 px-2 py-0.5" + | backgroundColor: "#f9d75f" + | borderRadius: 9007199254740990 + | paddingBlock: 1.75 + | paddingInline: 7 - ~ className: "text-darker font-sans text-xs font-bold" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | lineHeight: 14 "Included in early access" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Draw each card yourself — a fresh deal for every question." `; -exports[`Settings sheet renders the menu unchanged 1`] = ` +exports[`Settings sheet renders the menu 1`] = ` | flex: 1 - ~ className: "flex-1 justify-end" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "flex-end" - ~ className: "absolute inset-0" + | inset: 0 + | position: "absolute" - ~ className: "bg-white dark:bg-dark rounded-t-3xl" + | backgroundColor: "#f5f5f5" + | borderTopLeftRadius: 21 + | borderTopRightRadius: 21 | paddingBottom: 24 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Settings" @@ -523,17 +931,41 @@ exports[`Settings sheet renders the menu unchanged 1`] = ` | maxHeight: 1067.2 - ~ className: "text-mutedOnLight dark:text-gray-dark px-5 pb-1 pt-4 font-sans text-xs font-bold uppercase tracking-wide" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | letterSpacing: 0.26 + | lineHeight: 14 + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 14 + | textTransform: "uppercase" "Gameplay" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Game" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "Classic" | color: "#6b6d82" @@ -543,14 +975,29 @@ exports[`Settings sheet renders the menu unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Language" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "English" | color: "#6b6d82" @@ -560,14 +1007,29 @@ exports[`Settings sheet renders the menu unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#dcdee9" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Second language" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "None" | color: "#6b6d82" @@ -577,17 +1039,32 @@ exports[`Settings sheet renders the menu unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "px-5 pb-1 pt-5" + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 17.5 - ~ className: "text-darker dark:text-white font-title text-lg" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 15.75 + | lineHeight: 24.5 "Tabletop mode" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Lay the phone flat on the table — the question mirrors on top so both sides can read it at once." - ~ className: "flex-row items-center justify-between px-5 py-3 " + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 10.5 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Two-way readable" | color: "#6b6d82" @@ -597,17 +1074,42 @@ exports[`Settings sheet renders the menu unchanged 1`] = ` | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark border-gray-lighter dark:border-white/10 mt-2 border-t px-5 pb-1 pt-5 font-sans text-xs font-bold uppercase tracking-wide" + | borderColor: "#dcdee9" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | letterSpacing: 0.26 + | lineHeight: 14 + | marginTop: 7 + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 17.5 + | textTransform: "uppercase" "Appearance" - ~ className: "flex-row items-center justify-between px-5 py-4" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Theme" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#6b6d82" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "System" | color: "#6b6d82" @@ -618,23 +1120,39 @@ exports[`Settings sheet renders the menu unchanged 1`] = ` "" `; -exports[`Settings sheet renders the menu unchanged in the Dark Theme Display setting 1`] = ` +exports[`Settings sheet renders the menu in the Dark Theme Display setting 1`] = ` | flex: 1 - ~ className: "flex-1 justify-end" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "flex-end" - ~ className: "absolute inset-0" + | inset: 0 + | position: "absolute" - ~ className: "bg-white dark:bg-dark rounded-t-3xl" + | backgroundColor: "#262432" + | borderTopLeftRadius: 21 + | borderTopRightRadius: 21 | paddingBottom: 24 - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#f5f5f51a" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 | paddingTop: 16 - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#f5f5f5" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Settings" @@ -648,17 +1166,41 @@ exports[`Settings sheet renders the menu unchanged in the Dark Theme Display set | maxHeight: 1067.2 - ~ className: "text-mutedOnLight dark:text-gray-dark px-5 pb-1 pt-4 font-sans text-xs font-bold uppercase tracking-wide" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | letterSpacing: 0.26 + | lineHeight: 14 + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 14 + | textTransform: "uppercase" "Gameplay" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#f5f5f51a" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Game" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "Classic" | color: "#9698af" @@ -668,14 +1210,29 @@ exports[`Settings sheet renders the menu unchanged in the Dark Theme Display set | fontWeight: "normal" "" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#f5f5f51a" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Language" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "English" | color: "#9698af" @@ -685,14 +1242,29 @@ exports[`Settings sheet renders the menu unchanged in the Dark Theme Display set | fontWeight: "normal" "" - ~ className: "border-gray-lighter dark:border-white/10 flex-row items-center justify-between border-b px-5 py-4" + | alignItems: "center" + | borderBottomStyle: "solid" + | borderBottomWidth: 1 + | borderColor: "#f5f5f51a" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Second language" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "None" | color: "#9698af" @@ -702,17 +1274,32 @@ exports[`Settings sheet renders the menu unchanged in the Dark Theme Display set | fontWeight: "normal" "" - ~ className: "px-5 pb-1 pt-5" + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 17.5 - ~ className: "text-darker dark:text-white font-title text-lg" + | color: "#f5f5f5" + | fontFamily: "aptly" + | fontSize: 15.75 + | lineHeight: 24.5 "Tabletop mode" - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-sm" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 12.25 + | lineHeight: 17.5 "Lay the phone flat on the table — the question mirrors on top so both sides can read it at once." - ~ className: "flex-row items-center justify-between px-5 py-3 " + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 10.5 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Two-way readable" | color: "#9698af" @@ -722,17 +1309,42 @@ exports[`Settings sheet renders the menu unchanged in the Dark Theme Display set | fontWeight: "normal" "" - ~ className: "text-mutedOnLight dark:text-gray-dark border-gray-lighter dark:border-white/10 mt-2 border-t px-5 pb-1 pt-5 font-sans text-xs font-bold uppercase tracking-wide" + | borderColor: "#f5f5f51a" + | borderTopStyle: "solid" + | borderTopWidth: 1 + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 10.5 + | fontWeight: 700 + | letterSpacing: 0.26 + | lineHeight: 14 + | marginTop: 7 + | paddingBottom: 3.5 + | paddingInline: 17.5 + | paddingTop: 17.5 + | textTransform: "uppercase" "Appearance" - ~ className: "flex-row items-center justify-between px-5 py-4" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBlock: 14 + | paddingInline: 17.5 - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#f5f5f5" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Theme" - ~ className: "flex-row items-center gap-1.5" + | alignItems: "center" + | flexDirection: "row" + | gap: 5.25 - ~ className: "text-mutedOnLight dark:text-gray-dark font-sans text-base" + | color: "#9698af" + | fontFamily: "golos-text" + | fontSize: 14 + | lineHeight: 21 "Dark" | color: "#9698af" @@ -743,31 +1355,50 @@ exports[`Settings sheet renders the menu unchanged in the Dark Theme Display set "" `; -exports[`Share sheet renders all three rows unchanged for a Pool-backed Deck 1`] = ` +exports[`Share sheet renders all three rows for a Pool-backed Deck 1`] = ` | flex: 1 | flex: 1 - ~ className: "flex-1 justify-end" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "flex-end" - ~ className: "absolute inset-0" + | inset: 0 + | position: "absolute" - ~ className: "bg-white dark:bg-dark rounded-t-3xl" + | backgroundColor: "#f5f5f5" + | borderTopLeftRadius: 21 + | borderTopRightRadius: 21 | paddingBottom: 24 - ~ className: "items-center pb-1 pt-3" + | alignItems: "center" + | paddingBottom: 3.5 + | paddingTop: 10.5 - ~ className: "bg-gray-lighter dark:bg-white/20 h-1 w-10 rounded-full" + | backgroundColor: "#dcdee9" + | borderRadius: 9007199254740990 + | height: 3.5 + | width: 35 | maxHeight: 1067.2 - ~ className: "flex-row items-center justify-between px-5 pb-2 pt-2" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBottom: 7 + | paddingInline: 17.5 + | paddingTop: 7 - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Share" @@ -778,7 +1409,11 @@ exports[`Share sheet renders all three rows unchanged for a Pool-backed Deck 1`] | fontWeight: "normal" "" - ~ className: "flex-row items-center gap-3 px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | gap: 10.5 + | paddingBlock: 14 + | paddingInline: 17.5 | color: "#111516" | fontFamily: "ionicons" @@ -787,10 +1422,17 @@ exports[`Share sheet renders all three rows unchanged for a Pool-backed Deck 1`] | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Share link" - ~ className: "flex-row items-center gap-3 px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | gap: 10.5 + | paddingBlock: 14 + | paddingInline: 17.5 | color: "#111516" | fontFamily: "ionicons" @@ -799,10 +1441,17 @@ exports[`Share sheet renders all three rows unchanged for a Pool-backed Deck 1`] | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Story image" - ~ className: "flex-row items-center gap-3 px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | gap: 10.5 + | paddingBlock: 14 + | paddingInline: 17.5 | color: "#111516" | fontFamily: "ionicons" @@ -811,35 +1460,57 @@ exports[`Share sheet renders all three rows unchanged for a Pool-backed Deck 1`] | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Post image" `; -exports[`Share sheet renders the link row alone unchanged for an inline-source Deck 1`] = ` +exports[`Share sheet renders the link row alone for an inline-source Deck 1`] = ` | flex: 1 | flex: 1 - ~ className: "flex-1 justify-end" + | flexBasis: "0%" + | flexGrow: 1 + | flexShrink: 1 + | justifyContent: "flex-end" - ~ className: "absolute inset-0" + | inset: 0 + | position: "absolute" - ~ className: "bg-white dark:bg-dark rounded-t-3xl" + | backgroundColor: "#f5f5f5" + | borderTopLeftRadius: 21 + | borderTopRightRadius: 21 | paddingBottom: 24 - ~ className: "items-center pb-1 pt-3" + | alignItems: "center" + | paddingBottom: 3.5 + | paddingTop: 10.5 - ~ className: "bg-gray-lighter dark:bg-white/20 h-1 w-10 rounded-full" + | backgroundColor: "#dcdee9" + | borderRadius: 9007199254740990 + | height: 3.5 + | width: 35 | maxHeight: 1067.2 - ~ className: "flex-row items-center justify-between px-5 pb-2 pt-2" + | alignItems: "center" + | flexDirection: "row" + | justifyContent: "space-between" + | paddingBottom: 7 + | paddingInline: 17.5 + | paddingTop: 7 - ~ className: "text-darker dark:text-white font-title text-2xl" + | color: "#111516" + | fontFamily: "aptly" + | fontSize: 21 + | lineHeight: 28 "Share" @@ -850,7 +1521,11 @@ exports[`Share sheet renders the link row alone unchanged for an inline-source D | fontWeight: "normal" "" - ~ className: "flex-row items-center gap-3 px-5 py-4 " + | alignItems: "center" + | flexDirection: "row" + | gap: 10.5 + | paddingBlock: 14 + | paddingInline: 17.5 | color: "#111516" | fontFamily: "ionicons" @@ -859,6 +1534,9 @@ exports[`Share sheet renders the link row alone unchanged for an inline-source D | fontWeight: "normal" "" - ~ className: "text-darker dark:text-white font-sans text-lg" + | color: "#111516" + | fontFamily: "golos-text" + | fontSize: 15.75 + | lineHeight: 24.5 "Share link" `; diff --git a/apps/mobile/src/__tests__/color-scheme.test.ts b/apps/mobile/src/__tests__/color-scheme.test.ts new file mode 100644 index 00000000..18d09cd1 --- /dev/null +++ b/apps/mobile/src/__tests__/color-scheme.test.ts @@ -0,0 +1,133 @@ +/** + * src/lib/color-scheme.ts — the module that turns the stored Theme (a Display + * setting, CONTEXT.md) into the light/dark value every `dark:` class and every + * JS-level colour conditional reads. + * + * Two of its behaviours are invisible until they break in someone's hands: + * + * - the `?? 'dark'` fallback for a platform that reports no OS preference at + * all, without which `dark:` and its light-mode counterpart would *both* + * miss and the app would render unstyled; + * - re-asserting a manual override after an OS appearance flip. react-native-css + * registers its own `Appearance` listener that mirrors the OS value straight + * into the observable, so a phone that switches to Dark at sunset would + * silently drag a player who chose Light along with it. + * + * Both happen at *import* time — the module seeds the observable and registers + * its listener as it loads — so each case gets its own module registry. + */ +import {Appearance} from 'react-native' +import type {colorScheme} from 'react-native-css' + +import type {getColorScheme, setColorScheme} from '@/lib/color-scheme' + +type Api = {getColorScheme: typeof getColorScheme; setColorScheme: typeof setColorScheme} +type Observable = typeof colorScheme +type OsScheme = 'light' | 'dark' | null + +/** A fresh copy of the module, loaded against a stubbed OS preference. */ +const load = (initialOsScheme: OsScheme) => { + let os = initialOsScheme + let api!: Api + let observable!: Observable + const osListeners: (() => void)[] = [] + + const stubOsPreference = (appearance: typeof Appearance) => { + jest.spyOn(appearance, 'getColorScheme').mockImplementation(() => os) + jest.spyOn(appearance, 'addChangeListener').mockImplementation((listener) => { + // 'unspecified' is how the change event spells "no OS preference"; the + // getter above spells the same thing null. + osListeners.push(() => listener({colorScheme: os ?? 'unspecified'})) + return {remove: () => undefined} + }) + } + + // Synchronous requires, not `await import()`: only a synchronous require lands + // in the registry isolateModules swaps in, and the stub has to go on between + // requiring react-native and requiring the module that reads it on the way up. + jest.isolateModules(() => { + stubOsPreference(jest.requireActual<{Appearance: typeof Appearance}>('react-native').Appearance) + observable = jest.requireActual<{colorScheme: Observable}>('react-native-css').colorScheme + api = jest.requireActual('@/lib/color-scheme') + }) + + // The same stub again, on this file's own registry. `react-native`'s index is + // a lazy-getter object — `Appearance` re-`require`s the module on every + // property access, through whichever registry is live at that moment — so + // color-scheme.ts reads the isolated copy while it is being imported and this + // one for every call afterwards. Stubbing only the first leaves it reading the + // host's real (null) preference from the second assertion onwards. + stubOsPreference(Appearance) + + /** + * One OS-level appearance flip, in the order the device produces it: + * react-native-css's own listener mirrors the new value into the observable + * first (it was registered first, when it was imported), then ours runs. + */ + const flipOsTo = (next: 'light' | 'dark') => { + os = next + observable.set(next) + for (const listener of osListeners) listener() + } + + return {...api, observable, flipOsTo} +} + +afterEach(() => jest.restoreAllMocks()) + +describe('color-scheme — resolving the OS preference', () => { + it('follows the OS while the setting is System', () => { + const {getColorScheme} = load('light') + expect(getColorScheme()).toBe('light') + }) + + it('falls back to dark when the OS reports no preference', () => { + const {getColorScheme, observable} = load(null) + + expect(getColorScheme()).toBe('dark') + // Seeded, not merely computed: `dark:` compares against the observable's + // own value, so leaving it null would make every variant miss. + expect(observable.get()).toBe('dark') + }) + + it('follows the OS across a flip while the setting is System', () => { + const {getColorScheme, flipOsTo} = load('light') + + flipOsTo('dark') + + expect(getColorScheme()).toBe('dark') + }) +}) + +describe('color-scheme — manual overrides', () => { + it('overrides the OS preference', () => { + const {getColorScheme, setColorScheme} = load('dark') + + setColorScheme('light') + + expect(getColorScheme()).toBe('light') + }) + + it('re-asserts the override after an OS appearance flip', () => { + const {getColorScheme, setColorScheme, observable, flipOsTo} = load('light') + setColorScheme('light') + + // Sunset: the phone switches itself to Dark. react-native-css writes that + // into the observable; our listener has to write the override back. + flipOsTo('dark') + + expect(getColorScheme()).toBe('light') + expect(observable.get()).toBe('light') + }) + + it('hands control back to the OS when the setting returns to System', () => { + const {getColorScheme, setColorScheme, flipOsTo} = load('light') + setColorScheme('dark') + + setColorScheme('system') + expect(getColorScheme()).toBe('light') + + flipOsTo('dark') + expect(getColorScheme()).toBe('dark') + }) +}) diff --git a/apps/mobile/src/__tests__/player-bar.test.tsx b/apps/mobile/src/__tests__/player-bar.test.tsx index 79de1021..cfba318c 100644 --- a/apps/mobile/src/__tests__/player-bar.test.tsx +++ b/apps/mobile/src/__tests__/player-bar.test.tsx @@ -18,20 +18,13 @@ import React from 'react' import {StyleSheet} from 'react-native' import {act, fireEvent, render, screen} from '@testing-library/react-native' -import {colorScheme} from 'nativewind' +import {setColorScheme} from '@/lib/color-scheme' import {colors} from '@whocards/tokens' jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({top: 0, bottom: 0, left: 0, right: 0}), })) -afterEach(async () => { - // NativeWind's colorScheme is a global observable — reset it so a test that - // sets it doesn't bleed into whichever test runs next (mirrors - // settings-modal.test.tsx). - await act(() => colorScheme.set('system')) -}) - // PressableScale drives its press animation through react-native-reanimated / // react-native-worklets, whose native module isn't available under plain // jest-expo (unrelated to this test — see pressable-scale.tsx). Swap it for a @@ -55,8 +48,10 @@ import {PlayerBar} from '../components/player-bar' const noop = () => {} -const renderBar = (props: Partial> = {}) => - render() +const renderBar = async (props: Partial> = {}) => + await render( + + ) /** * The color of every rendered `Ionicons` glyph. @@ -120,7 +115,7 @@ describe('PlayerBar — Exit button (issue #186)', () => { describe('PlayerBar — themed icon color (issue #173)', () => { it('uses white icons when the resolved scheme is dark', async () => { - await act(() => colorScheme.set('dark')) + await act(() => setColorScheme('dark')) await renderBar() const icons = iconColors() expect(icons.length).toBeGreaterThan(0) @@ -130,7 +125,7 @@ describe('PlayerBar — themed icon color (issue #173)', () => { }) it('uses darker icons when the resolved scheme is light', async () => { - await act(() => colorScheme.set('light')) + await act(() => setColorScheme('light')) await renderBar() const icons = iconColors() expect(icons.length).toBeGreaterThan(0) diff --git a/apps/mobile/src/__tests__/question-text.test.tsx b/apps/mobile/src/__tests__/question-text.test.tsx index 070bf9ef..f1fd0ec1 100644 --- a/apps/mobile/src/__tests__/question-text.test.tsx +++ b/apps/mobile/src/__tests__/question-text.test.tsx @@ -9,8 +9,11 @@ * `fitSecondaryFontSize`'s headroom growth (issue #189). */ import React from 'react' -import {render, screen} from '@testing-library/react-native' +import {act, render, screen} from '@testing-library/react-native' +import {colors} from '@whocards/tokens' +import {setColorScheme} from '@/lib/color-scheme' +import {resolvedStyle, withOpacity} from '@/test-utils/resolved-style' import {QuestionText, fitFontSize, fitSecondaryFontSize} from '../components/question-text' describe('fitFontSize — minFont override', () => { @@ -113,6 +116,12 @@ describe('QuestionText — Tabletop mode (mirrored, issue #148)', () => { }) }) +// These read the *resolved* colour rather than the `className` string: under +// NativeWind v5 the class is compiled away before it reaches the element, so +// asserting on the prop would pass even with the style pipeline dead. Going +// through the token values checks the whole chain — class → theme → token. +const textColor = (text: string) => resolvedStyle(screen.getByText(text)).color + describe('QuestionText — themedText (issue #173, final)', () => { const box = {width: 300, height: 400} const question = 'What matters to you right now?' @@ -127,11 +136,26 @@ describe('QuestionText — themedText (issue #173, final)', () => { secondaries={[{language: 'es', text: secondaryText}]} /> ) - expect(screen.getByText(question).props.className).toBe('text-white') - expect(screen.getByText(secondaryText).props.className).toBe('text-white/70') + expect(textColor(question)).toBe(colors.white) + expect(textColor(secondaryText)).toBe(withOpacity(colors.white, 70)) + }) + + it('stays white on a card surface even in light mode — the card is always dark', async () => { + await act(() => setColorScheme('light')) + await render( + + ) + expect(textColor(question)).toBe(colors.white) + expect(textColor(secondaryText)).toBe(withOpacity(colors.white, 70)) }) it('follows the theme when themedText is set — classic play on the themed canvas', async () => { + await act(() => setColorScheme('light')) await render( { themedText /> ) - expect(screen.getByText(question).props.className).toBe('text-darker dark:text-white') - expect(screen.getByText(secondaryText).props.className).toBe( - 'text-darker/70 dark:text-white/70' + expect(textColor(question)).toBe(colors.darker) + expect(textColor(secondaryText)).toBe(withOpacity(colors.darker, 70)) + }) + + it('flips to white when themedText meets the dark scheme', async () => { + await act(() => setColorScheme('dark')) + await render( + ) + expect(textColor(question)).toBe(colors.white) + expect(textColor(secondaryText)).toBe(withOpacity(colors.white, 70)) }) it('threads themedText through both mirrored (Tabletop) halves', async () => { + await act(() => setColorScheme('light')) await render() for (const node of screen.getAllByText(question, {includeHiddenElements: true})) { - expect(node.props.className).toBe('text-darker dark:text-white') + expect(resolvedStyle(node).color).toBe(colors.darker) } }) }) @@ -170,7 +208,7 @@ describe('QuestionText — secondary language sizing (issue #189)', () => { secondaries={[{language: 'es', text: secondaryText}]} /> ) - const shortPrimarySecondarySize = short.getByText(secondaryText).props.style.fontSize + const shortPrimarySecondarySize = Number(resolvedStyle(short.getByText(secondaryText)).fontSize) const long = await render( { secondaries={[{language: 'es', text: secondaryText}]} /> ) - const longPrimarySecondarySize = long.getByText(secondaryText).props.style.fontSize + const longPrimarySecondarySize = Number(resolvedStyle(long.getByText(secondaryText)).fontSize) // A short question leaves the primary's fit with headroom (issue #189) — // the secondary grows to use some of it. A long question pins the primary diff --git a/apps/mobile/src/__tests__/secondary-button.test.tsx b/apps/mobile/src/__tests__/secondary-button.test.tsx index 832c905d..2fe6945d 100644 --- a/apps/mobile/src/__tests__/secondary-button.test.tsx +++ b/apps/mobile/src/__tests__/secondary-button.test.tsx @@ -7,6 +7,9 @@ import React from 'react' import {Text} from 'react-native' import {fireEvent, render, screen} from '@testing-library/react-native' +import {colors, fonts} from '@whocards/tokens' + +import {resolvedStyle} from '@/test-utils/resolved-style' // PressableScale drives its press animation through react-native-reanimated / // react-native-worklets, whose native module isn't available under plain @@ -50,6 +53,18 @@ describe('SecondaryButton', () => { expect(screen.queryByLabelText('Settings')).toBeNull() }) + it('styles its label from the design tokens — white, on the sans face, bold', async () => { + // Reading the resolved style rather than the `className` string: NativeWind + // v5 compiles the class away before it reaches the element, so a className + // assertion would still pass with the whole style pipeline broken. + await render( {}} />) + expect(resolvedStyle(screen.getByText('Settings'))).toMatchObject({ + color: colors.white, + fontFamily: fonts.sans.family, + fontWeight: 700, + }) + }) + it('renders an optional leading icon', async () => { await render( icon} onPress={() => {}} /> diff --git a/apps/mobile/src/__tests__/share-modal.test.tsx b/apps/mobile/src/__tests__/share-modal.test.tsx index a13ef3d8..9ab252df 100644 --- a/apps/mobile/src/__tests__/share-modal.test.tsx +++ b/apps/mobile/src/__tests__/share-modal.test.tsx @@ -42,7 +42,7 @@ import React from 'react' import {Share} from 'react-native' import {act, fireEvent, render, screen} from '@testing-library/react-native' import {StatusBar} from 'expo-status-bar' -import {colorScheme} from 'nativewind' +import {setColorScheme} from '@/lib/color-scheme' jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({top: 0, bottom: 0, left: 0, right: 0}), @@ -76,13 +76,6 @@ jest.mock('@/lib/share-image', () => ({ import {ShareModal} from '../components/share-modal' -afterEach(async () => { - // NativeWind's colorScheme is a global observable — reset it so a test that - // sets it doesn't bleed into whichever test runs next (mirrors - // settings-modal.test.tsx). - await act(() => colorScheme.set('system')) -}) - const PROPS = { questionText: 'What is your favorite memory?', shareUrl: 'https://whocards.cc/play?lang=en&q=q-1', @@ -274,13 +267,13 @@ describe('ShareModal — StatusBar override (issue #173)', () => { }) it('shows light (white) status-bar icons when the resolved scheme is dark', async () => { - await act(() => colorScheme.set('dark')) + await act(() => setColorScheme('dark')) await render( {}} onClose={() => {}} />) expect(lastStatusBarStyle()).toBe('light') }) it('shows dark status-bar icons when the resolved scheme is light', async () => { - await act(() => colorScheme.set('light')) + await act(() => setColorScheme('light')) await render( {}} onClose={() => {}} />) expect(lastStatusBarStyle()).toBe('dark') }) diff --git a/apps/mobile/src/__tests__/tailwind-theme.test.tsx b/apps/mobile/src/__tests__/tailwind-theme.test.tsx new file mode 100644 index 00000000..e1d89a0a --- /dev/null +++ b/apps/mobile/src/__tests__/tailwind-theme.test.tsx @@ -0,0 +1,211 @@ +/** + * The design-token → Tailwind → NativeWind pipeline, asserted end to end. + * + * Every screen in this app is styled with `className`, and nothing else in CI + * runs Metro or a simulator — so if the Tailwind theme stopped being built from + * @whocards/tokens, or NativeWind stopped resolving classes altogether, every + * other test here would still pass and the app would ship unstyled. This file + * is the guard: `jest.global-setup.js` compiles the real `src/global.css` + * through the same PostCSS/Tailwind + react-native-css pipeline Metro uses, and + * the cases below render each token's utility class and read the resolved style + * back. + * + * The case tables are checked for completeness against the token objects + * themselves, so adding a token to @whocards/tokens fails this file until the + * token is covered. The class names must stay written out in full: Tailwind + * only emits a utility it has seen literally in a scanned source file, and + * `src/**` (this file included) is what `global.css` scans. + */ +import React from 'react' +import {Text, View} from 'react-native' +import {act, render, screen} from '@testing-library/react-native' +import {colors, fonts, radius, spacing} from '@whocards/tokens' + +import {resolvedStyle, rem, withOpacity} from '@/test-utils/resolved-style' +import {setColorScheme} from '@/lib/color-scheme' + +const styleFor = async (className: string) => { + await render() + return resolvedStyle(screen.getByTestId('subject')) +} + +/** Every `--color-*` the theme should expose, as `[utility, token value]`. */ +const COLOR_CASES: [className: string, expected: string][] = [ + ['bg-yellow-100', colors.yellow[100]], + ['bg-yellow-300', colors.yellow[300]], + ['bg-yellow-400', colors.yellow[400]], + ['bg-yellow-500', colors.yellow[500]], + ['bg-gray', colors.gray.DEFAULT], + ['bg-gray-lighter', colors.gray.lighter], + ['bg-gray-light', colors.gray.light], + ['bg-gray-dark', colors.gray.dark], + ['bg-primary-light', colors.primary.light], + ['bg-primary-dark', colors.primary.dark], + ['bg-dark', colors.dark], + ['bg-darker', colors.darker], + ['bg-darkest', colors.darkest], + ['bg-background', colors.background], + ['bg-white', colors.white], + ['bg-red', colors.red], + ['bg-canvasLight', colors.canvasLight], + ['bg-mutedOnLight', colors.mutedOnLight], + ['bg-accentOnLight', colors.accentOnLight], + ['bg-errorOnLight', colors.errorOnLight], + ['bg-accentOnDark', colors.accentOnDark], + ['bg-errorOnDark', colors.errorOnDark], +] + +describe('Tailwind theme — colours', () => { + it.each(COLOR_CASES)('%s resolves to the token value', async (className, expected) => { + expect((await styleFor(className)).backgroundColor).toBe(expected) + }) + + it('covers every colour token — a new token must be added above', () => { + const covered = new Set(COLOR_CASES.map(([className]) => className)) + const expected = Object.entries(colors).flatMap(([name, value]) => + typeof value === 'string' + ? [`bg-${name}`] + : Object.keys(value).map((shade) => + shade === 'DEFAULT' ? `bg-${name}` : `bg-${name}-${shade}` + ) + ) + expect([...covered].toSorted()).toEqual([...new Set(expected)].toSorted()) + }) +}) + +/** The custom spacing/radius steps, in the px their `rem` values compile to. */ +const SPACING_CASES: [className: string, expected: number][] = [ + ['p-4.5', rem(spacing['4.5'])], + ['p-7.5', rem(spacing['7.5'])], +] + +describe('Tailwind theme — spacing and radius', () => { + it.each(SPACING_CASES)('%s resolves to the token value in px', async (className, expected) => { + expect((await styleFor(className)).padding).toBe(expected) + }) + + it('covers every custom spacing step', () => { + expect(SPACING_CASES.map(([className]) => className)).toEqual( + Object.keys(spacing).map((name) => `p-${name}`) + ) + }) + + it('rounded-2.5xl resolves to the token value in px', async () => { + expect((await styleFor('rounded-2.5xl')).borderRadius).toBe(rem(radius['2.5xl'])) + }) + + it('covers every custom radius step', () => { + expect(Object.keys(radius)).toEqual(['2.5xl']) + }) +}) + +/** + * `font-*` maps to the *registered* face, not the whole CSS stack: the tokens + * carry a web fallback list, and react-native-css takes the first family from + * it — which is the name `expo-font` loads the file under. + */ +const FONT_CASES: [className: string, expected: string][] = [ + ['font-sans', fonts.sans.family], + ['font-title', fonts.title.family], + ['font-chinese', fonts.chinese.family], + ['font-hebrew', fonts.hebrew.family], + ['font-japanese', fonts.japanese.family], +] + +describe('Tailwind theme — fonts', () => { + it.each(FONT_CASES)('%s resolves to the registered family', async (className, expected) => { + await render({className}) + expect(resolvedStyle(screen.getByText(className)).fontFamily).toBe(expected) + }) + + it('covers every font token', () => { + expect(FONT_CASES.map(([className]) => className)).toEqual( + Object.keys(fonts).map((name) => `font-${name}`) + ) + }) + + it('keeps golos-text as the default sans face, beating nativewind/theme’s System', async () => { + // nativewind/theme ships an unlayered `@media ios { :root { --font-sans: System } }`, + // which outranks anything in a cascade layer — including our @theme block. + // src/global.css re-declares --font-sans after that import to win it back; + // this is the assertion that the ordering still holds. + expect((await styleFor('font-sans')).fontFamily).toBe(fonts.sans.family) + }) +}) + +/** + * Line height — the one silently breaking change in the NativeWind v5 upgrade. + * + * v4 emitted `leading-` as a `rem` length (`leading-8` = 2rem = 28px). v5's + * `nativewind/theme` overrides the utility to a *unitless* value, which + * react-native-css multiplies by the element's own font size — so the same + * `leading-8` is now 2em, which is 35px at `text-xl`. Nothing throws when that + * changes; the text just gets taller. These are the cases that would catch it. + * + * The migration recipe is "divide the old value by the font size": v4's + * `text-xl leading-8` is 2rem ÷ 1.25rem = 1.6em, and each v5 step is 0.25em, so + * the exact equivalent would be `leading-6.4` — which the override can't + * express (it takes integers only). Nor can an arbitrary value: `leading-[…]` + * does fall through to Tailwind's built-in utility and compiles fine, but + * react-native-css accepts only a plain number for lineHeight, so + * `leading-[1.6]`, `leading-[1.6em]`, `leading-[2rem]` and `leading-[28px]` all + * resolve to no lineHeight at all (measured). `leading-6` (1.5em = 26.25px) is + * the nearest step, and what src/app/index.tsx's tagline now uses — 1.75px + * tighter than v4's 28px, and the migration's one accepted visual change. + */ +const renderClass = async (className: string) => { + await render() + return screen.getByTestId('line') +} + +const lineHeightOf = async (className: string) => + resolvedStyle(await renderClass(className)).lineHeight + +describe('Tailwind theme — line height', () => { + it('gives each text size its own line height, in px', async () => { + // Each `text-*` carries a line height distinct from its font size, and the + // two are asserted together so neither reading can be mistaken for the + // other: text-sm is 0.875rem/1.25rem, text-xl is 1.25rem/1.75rem. + // + // v4 emitted those line heights as rem lengths; v5 emits Tailwind's + // `calc( / )` em ratio instead — 1.4em for text-xl + // — which lands on the same pixel value by construction, for any font size + // expressed in rem. That equality is the thing worth pinning: it is why + // `text-sm`'s default is exactly what v4's `text-sm leading-5` pinned, and + // so why theme-settings-page.tsx could drop that class outright rather than + // restate it as a ratio. + expect(resolvedStyle(await renderClass('text-sm'))).toMatchObject({ + fontSize: rem('0.875rem'), + lineHeight: rem('1.25rem'), + }) + expect(resolvedStyle(await renderClass('text-xl'))).toMatchObject({ + fontSize: rem('1.25rem'), + lineHeight: rem('1.75rem'), + }) + }) + + it('parses a leading-* step as 0.25em, not 0.25rem', async () => { + // Against text-xl's 1.25rem (17.5px) font size. + expect(await lineHeightOf('text-xl leading-6')).toBe(1.5 * rem('1.25rem')) + expect(await lineHeightOf('text-xl leading-8')).toBe(2 * rem('1.25rem')) + }) +}) + +describe('Tailwind theme — modifiers', () => { + it('applies the /NN opacity modifier to a token colour', async () => { + expect((await styleFor('bg-primary-dark/25')).backgroundColor).toBe( + withOpacity(colors.primary.dark, 25) + ) + expect((await styleFor('bg-white/70')).backgroundColor).toBe(withOpacity(colors.white, 70)) + }) + + it('switches on `dark:` with the app’s colour-scheme setting', async () => { + await act(() => setColorScheme('light')) + expect((await styleFor('bg-canvasLight dark:bg-darkest')).backgroundColor).toBe( + colors.canvasLight + ) + + await act(() => setColorScheme('dark')) + expect((await styleFor('bg-canvasLight dark:bg-darkest')).backgroundColor).toBe(colors.darkest) + }) +}) diff --git a/apps/mobile/src/__tests__/theme-settings-page.test.tsx b/apps/mobile/src/__tests__/theme-settings-page.test.tsx index 7679f8a5..a139ef38 100644 --- a/apps/mobile/src/__tests__/theme-settings-page.test.tsx +++ b/apps/mobile/src/__tests__/theme-settings-page.test.tsx @@ -9,7 +9,11 @@ * only one now), so there's nothing to test about those here. */ import React from 'react' -import {fireEvent, render, screen} from '@testing-library/react-native' +import {act, fireEvent, render, screen} from '@testing-library/react-native' +import {colors} from '@whocards/tokens' + +import {setColorScheme} from '@/lib/color-scheme' +import {rem, resolvedStyle} from '@/test-utils/resolved-style' jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({top: 0, bottom: 0, left: 0, right: 0}), @@ -17,11 +21,11 @@ jest.mock('react-native-safe-area-context', () => ({ import {ThemeSettingsPage} from '../components/theme-settings-page' -const renderPage = ( +const renderPage = async ( current: 'system' | 'light' | 'dark' = 'system', onSelect = jest.fn(), onBack = jest.fn() -) => render() +) => await render() describe('ThemeSettingsPage — options (issue #189, third pass)', () => { it('marks the current theme selected', async () => { @@ -41,6 +45,18 @@ describe('ThemeSettingsPage — options (issue #189, third pass)', () => { expect(onSelect).toHaveBeenCalledWith('light') }) + it('keeps the explanatory copy at its pre-NativeWind-5 line height', async () => { + // This copy used to carry `leading-5` on top of `text-sm`. NativeWind v5 + // reads a leading-* step as em rather than rem, which would have squashed + // 1.25rem (17.5px) to 1.4285…em × 12.25px (15.3px) — so the class was + // dropped. text-sm's own line height was already exactly 1.25rem, so this + // is the value the class was pinning all along; assert it, because nothing + // about the rendered output says the class is gone on purpose. + await renderPage() + const copy = await screen.findByText(/Matches your device by default/) + expect(resolvedStyle(copy).lineHeight).toBe(rem('1.25rem')) + }) + it('reports the back arrow press via onBack', async () => { const onBack = jest.fn() await renderPage('system', undefined, onBack) @@ -49,3 +65,28 @@ describe('ThemeSettingsPage — options (issue #189, third pass)', () => { expect(onBack).toHaveBeenCalled() }) }) + +// The selected pill is the only signal that a segmented control has a +// selection, and it comes entirely from a conditional `className`. Asserting the +// *resolved* background (rather than the class string, which NativeWind v5 +// compiles away before render) checks both the conditional and the theme behind it. +const background = (label: string) => resolvedStyle(screen.getByLabelText(label)).backgroundColor + +describe('ThemeSettingsPage — selected segment styling', () => { + it('fills the selected segment with white on light, leaving the others bare', async () => { + await act(() => setColorScheme('light')) + await renderPage('dark') + await screen.findByLabelText('Theme: Dark') + expect(background('Theme: Dark')).toBe(colors.white) + expect(background('Theme: System')).toBeUndefined() + expect(background('Theme: Light')).toBeUndefined() + }) + + it('fills the selected segment with the dark token on dark', async () => { + await act(() => setColorScheme('dark')) + await renderPage('light') + await screen.findByLabelText('Theme: Light') + expect(background('Theme: Light')).toBe(colors.dark) + expect(background('Theme: System')).toBeUndefined() + }) +}) diff --git a/apps/mobile/src/__tests__/ui-snapshot-screens.test.tsx b/apps/mobile/src/__tests__/ui-snapshot-screens.test.tsx index ee7fa4ae..ab909120 100644 --- a/apps/mobile/src/__tests__/ui-snapshot-screens.test.tsx +++ b/apps/mobile/src/__tests__/ui-snapshot-screens.test.tsx @@ -3,13 +3,10 @@ * player (`src/app/play/[deck].tsx`, in each of its states) and the root layout * (`src/app/_layout.tsx`). * - * Same contract as ui-snapshot-surfaces.test.tsx: the baseline `.snap` is - * recorded on the PRE-upgrade toolchain and replayed untouched against the new - * one, so a difference here is a finding to read, never a `jest -u`. (Its first - * line is the exception: jest 30 refuses to read a snapshot file carrying - * jest 29's guide-link header, and that line is Jest's own file-format marker - * rather than test output. Everything below it is what the old toolchain - * recorded.) + * Same contract as ui-snapshot-surfaces.test.tsx: the snapshots are regression + * baselines for the upgraded React Native / NativeWind v5 toolchain. They cover + * the resolved JS render, including the CSS compiled and injected by Jest; + * native layout and rasterization still require comparison on a device. * * How much of each screen is real: * - Library and the player render for real, top to bottom — every component, @@ -42,9 +39,9 @@ import React from 'react' import {act, render, screen} from '@testing-library/react-native' import {GestureHandlerRootView} from 'react-native-gesture-handler' -import {colorScheme} from 'nativewind' +import {setColorScheme} from '@/lib/color-scheme' -import {uiSnapshot} from '@/test-utils/ui-snapshot' +import {flattenStyle, uiSnapshot} from '@/test-utils/ui-snapshot' // Whole screens, so slower than the per-component suites; see // settings-modal.test.tsx for why CI's shared runners need the headroom. @@ -57,10 +54,10 @@ jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock') ) -// The real `SafeAreaView` is a `View` plus whatever insets a provider reports; -// with no provider mounted those insets are zero anyway, so this is that minus -// the provider warning — and zero insets keep the padding in the snapshot fixed -// rather than device-shaped. +// The installed react-native-css safe-area adapter reexports the platform +// SafeAreaView; it provides insets but does not make that primitive CSS-aware. +// Use the raw native View here so this test exercises the same styling boundary +// as the device. The landing container uses explicit styles for that reason. jest.mock('react-native-safe-area-context', () => { const {View} = jest.requireActual('react-native') return { @@ -110,9 +107,8 @@ jest.mock('expo-router', () => { }) // The root layout imports the Tailwind entry stylesheet for Metro to compile; -// Jest has no CSS transform (and no Metro), so stub the module rather than let -// it try to parse `@tailwind base` as JavaScript. Nothing is lost that this -// format could have seen — see ui-snapshot.ts on unresolved classes. +// Jest compiles and injects the same stylesheet in its global setup, but still +// needs the module stub because Jest has no CSS module transform. jest.mock('../global.css', () => ({})) jest.mock('expo-splash-screen', () => ({ @@ -152,7 +148,9 @@ jest.mock('@/lib/language-store', () => ({ // storing 'dark', not by poking NativeWind's observable, which the restore would // immediately overwrite with the stored value anyway. jest.mock('@/lib/theme-store', () => ({ - getStoredTheme: jest.fn().mockResolvedValue('system'), + // Pin the default snapshot to light; the host machine's system appearance is + // otherwise allowed to change the deterministic baseline. + getStoredTheme: jest.fn().mockResolvedValue('light'), setStoredTheme: jest.fn().mockResolvedValue(undefined), })) @@ -184,7 +182,7 @@ import {getStoredGame} from '@/lib/game-store' import {getStoredTheme} from '@/lib/theme-store' import RootLayout from '../app/_layout' -import LandingScreen from '../app/index' +import LandingScreen, {libraryContainerStyle} from '../app/index' import PlayScreen from '../app/play/[deck]' /** Every screen sits under the root layout's gesture root on device. */ @@ -192,7 +190,12 @@ const Root = ({children}: {children: React.ReactNode}) => ( {children} ) -const renderUi = (ui: React.ReactElement) => render(ui, {wrapper: Root}) +const renderUi = async (ui: React.ReactElement) => await render(ui, {wrapper: Root}) + +// The safe-area adapter's SafeAreaView ignores className, so the Library's +// container spacing lives in explicit styles. Find the container by its own +// testID (not by the values under test) and pin the whole style object. +const librarySafeAreaStyle = () => flattenStyle(screen.getByTestId('library-safe-area').props.style) /** * Let the screen finish arriving. Both screens run their entrance behind a @@ -207,28 +210,28 @@ const settle = async () => { }) } -beforeEach(() => { +beforeEach(async () => { mockParams = {} // The Deck engine shuffles with Math.random; a snapshot needs one deal. jest.spyOn(Math, 'random').mockReturnValue(0.42) + await act(() => setColorScheme('light')) }) -afterEach(async () => { +// The colour scheme itself is reset globally in jest.setup.ts. +afterEach(() => { jest.restoreAllMocks() - // NativeWind's colorScheme is a global observable — reset it so a Dark-theme - // snapshot can't bleed into whichever test runs next. - await act(() => colorScheme.set('system')) }) describe('Library screen', () => { - it('renders the Library unchanged', async () => { + it('renders the Library screen', async () => { await renderUi() await screen.findByLabelText('Play') + expect(librarySafeAreaStyle()).toEqual(libraryContainerStyle) await settle() expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the Library unchanged in the Dark Theme Display setting', async () => { + it('renders the Library screen in the Dark Theme Display setting', async () => { jest.mocked(getStoredTheme).mockResolvedValueOnce('dark') await renderUi() await screen.findByLabelText('Play') @@ -238,7 +241,7 @@ describe('Library screen', () => { }) describe('Play screen', () => { - it('renders a Question unchanged', async () => { + it('renders a Question', async () => { // `?q=` opens the classic player at that Question in natural order instead // of shuffling (ADR-0003) — which is also what makes it snapshottable. mockParams = {deck: 'library', q: '1'} @@ -248,16 +251,16 @@ describe('Play screen', () => { expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders a Question unchanged in the Dark Theme Display setting', async () => { + it('renders a Question in the Dark Theme Display setting', async () => { mockParams = {deck: 'library', q: '1'} - await act(() => colorScheme.set('dark')) + await act(() => setColorScheme('dark')) await renderUi() await screen.findByLabelText('exit deck') await settle() expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the Pick a Card deck unchanged before a deal', async () => { + it('renders the Pick a Card deck before a deal', async () => { mockParams = {deck: 'library'} jest.mocked(getStoredGame).mockResolvedValueOnce('pick') await renderUi() @@ -266,7 +269,7 @@ describe('Play screen', () => { expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the unknown-Deck message unchanged', async () => { + it('renders the unknown-Deck message', async () => { mockParams = {deck: 'no-such-deck'} await renderUi() await screen.findByText('Deck not found.') @@ -277,14 +280,14 @@ describe('Play screen', () => { describe('Root layout', () => { // Only the themed screen-transition background is under test here; see the // header comment for why the navigator itself is stubbed. - it('renders the screen-transition background unchanged', async () => { + it('renders the screen-transition background', async () => { await render() await settle() expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the screen-transition background unchanged in Dark', async () => { - await act(() => colorScheme.set('dark')) + it('renders the screen-transition background in Dark', async () => { + await act(() => setColorScheme('dark')) await render() await settle() expect(uiSnapshot(screen)).toMatchSnapshot() diff --git a/apps/mobile/src/__tests__/ui-snapshot-surfaces.test.tsx b/apps/mobile/src/__tests__/ui-snapshot-surfaces.test.tsx index 41fc5ca9..4dd7d2ef 100644 --- a/apps/mobile/src/__tests__/ui-snapshot-surfaces.test.tsx +++ b/apps/mobile/src/__tests__/ui-snapshot-surfaces.test.tsx @@ -2,17 +2,10 @@ * UI snapshots of the app's interactive surfaces: the Settings sheet and its * pages, the Share sheet, and the player bar. * - * These exist to answer one question — did a dependency upgrade change what a - * player sees? — so the baseline `.snap` beside this file is recorded on the - * PRE-upgrade toolchain and replayed, untouched, against the new one. A - * snapshot generated on the upgrade branch would only ever describe the - * upgrade's own output and prove nothing, so if one of these ever differs, the - * answer is to read the diff, not to run `jest -u`. - * - * The one post-upgrade byte in the baseline file is its first line: jest 30 - * refuses to read a snapshot file carrying jest 29's guide-link header - * ("Outdated guide link"). That line is Jest's own file-format marker, not test - * output — everything below it is exactly what the old toolchain recorded. + * These are regression baselines for the upgraded React Native / NativeWind v5 + * toolchain. They include styles compiled from the app's CSS and injected into + * Jest, while native layout and rasterization still require comparison on a + * device. * * The format is `@/test-utils/ui-snapshot`: host type + flattened resolved * style + text + presentational accessibility props, and nothing else. See that @@ -25,7 +18,7 @@ import React from 'react' import {act, fireEvent, render, screen} from '@testing-library/react-native' import {GestureHandlerRootView} from 'react-native-gesture-handler' -import {colorScheme} from 'nativewind' +import {setColorScheme} from '@/lib/color-scheme' import {uiSnapshot} from '@/test-utils/ui-snapshot' @@ -91,12 +84,10 @@ const Root = ({children}: {children: React.ReactNode}) => ( {children} ) -const renderUi = (ui: React.ReactElement) => render(ui, {wrapper: Root}) +const renderUi = async (ui: React.ReactElement) => await render(ui, {wrapper: Root}) -afterEach(async () => { - // NativeWind's colorScheme is a global observable — reset it so a Dark-theme - // snapshot can't bleed into whichever test runs next. - await act(() => colorScheme.set('system')) +beforeEach(async () => { + await act(() => setColorScheme('light')) }) const renderSettings = (overrides: Partial> = {}) => @@ -113,20 +104,20 @@ const renderSettings = (overrides: Partial { - it('renders the menu unchanged', async () => { + it('renders the menu', async () => { await renderSettings() await screen.findByText('Settings') expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the menu unchanged in the Dark Theme Display setting', async () => { - await act(() => colorScheme.set('dark')) + it('renders the menu in the Dark Theme Display setting', async () => { + await act(() => setColorScheme('dark')) await renderSettings({theme: 'dark'}) await screen.findByText('Settings') expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders a risen page over the dimmed menu unchanged', async () => { + it('renders a risen page over the dimmed menu', async () => { await renderSettings() await fireEvent.press(await screen.findByLabelText('Game: Classic')) await screen.findByText('Choose your game') @@ -135,19 +126,19 @@ describe('Settings sheet', () => { }) describe('Settings pages', () => { - it('renders the Game page unchanged', async () => { + it('renders the Game page', async () => { await renderUi() await screen.findByText('Choose your game') expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the Theme page unchanged', async () => { + it('renders the Theme page', async () => { await renderUi() await screen.findByText('Theme') expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the Language page unchanged', async () => { + it('renders the Language page', async () => { await renderUi( ) @@ -155,7 +146,7 @@ describe('Settings pages', () => { expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the Second language page unchanged', async () => { + it('renders the Second language page', async () => { await renderUi( { onClose: noop, } - it('renders all three rows unchanged for a Pool-backed Deck', async () => { + it('renders all three rows for a Pool-backed Deck', async () => { await renderUi( { expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders the link row alone unchanged for an inline-source Deck', async () => { + it('renders the link row alone for an inline-source Deck', async () => { await renderUi() await screen.findByLabelText('Share link') expect(uiSnapshot(screen)).toMatchSnapshot() @@ -199,13 +190,13 @@ describe('Share sheet', () => { }) describe('Player bar', () => { - it('renders Prev / Exit / Share / Next unchanged', async () => { + it('renders Prev / Exit / Share / Next', async () => { await renderUi() await screen.findByLabelText('exit deck') expect(uiSnapshot(screen)).toMatchSnapshot() }) - it('renders without Share unchanged (the pick screen before a deal)', async () => { + it('renders without Share (the pick screen before a deal)', async () => { await renderUi( ) diff --git a/apps/mobile/src/__tests__/use-theme-setting.test.ts b/apps/mobile/src/__tests__/use-theme-setting.test.ts index e681295e..be5d8b89 100644 --- a/apps/mobile/src/__tests__/use-theme-setting.test.ts +++ b/apps/mobile/src/__tests__/use-theme-setting.test.ts @@ -6,9 +6,10 @@ * expressed as a `dark:` class (an `Ionicons` color, the Library screen's * `` override — see index.tsx). * - * `theme-store.ts` and `nativewind`'s `colorScheme`/`useColorScheme` run for - * real (only `AsyncStorage` is mocked) — this hook's whole job is composing - * the two, so mocking either away would test nothing. + * `theme-store.ts` and `src/lib/color-scheme.ts` (our thin wrapper over + * react-native-css's colour-scheme observable, which is what NativeWind v5 + * reads `dark:` off) run for real — only `AsyncStorage` is mocked. This hook's + * whole job is composing the two, so mocking either away would test nothing. * * Unlike theme-store.test.ts, this file does NOT `jest.resetModules()` * between tests: doing that alongside `renderHook` breaks React's internal diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index e25c7cde..124c728e 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,7 +1,6 @@ import {Stack} from 'expo-router' import * as SplashScreen from 'expo-splash-screen' import {StatusBar} from 'expo-status-bar' -import {colorScheme, useColorScheme} from 'nativewind' import {PostHogProvider} from 'posthog-react-native' import {useEffect} from 'react' import {GestureHandlerRootView} from 'react-native-gesture-handler' @@ -9,6 +8,7 @@ import {identify} from '@whocards/observability' import {colors} from '@whocards/tokens' import {ErrorBoundary} from '@/components/error-boundary' +import {setColorScheme, useColorScheme} from '@/lib/color-scheme' import {getDeviceId} from '@/lib/device-id' import {initObservability, posthog} from '@/lib/observability' import {getStoredTheme} from '@/lib/theme-store' @@ -22,12 +22,11 @@ void getDeviceId().then((id) => identify(id)) // Restore a previously-chosen Theme override as early as possible (issue #163, // a Display setting — CONTEXT.md), before the first paint that could show the -// wrong scheme. NativeWind already follows the OS Appearance out of the box -// with nothing called (`colorScheme.get()` falls back to it), so this only -// needs to act when the player has an explicit stored override; calling -// `colorScheme.set('system')` for the (common) unset/default case is a no-op -// in effect, just an explicit one. -void getStoredTheme().then((stored) => colorScheme.set(stored)) +// wrong scheme. `@/lib/color-scheme` already follows the OS Appearance out of +// the box, so this only needs to act when the player has an explicit stored +// override; calling `setColorScheme('system')` for the (common) unset/default +// case is a no-op in effect, just an explicit one. +void getStoredTheme().then((stored) => setColorScheme(stored)) import '../global.css' @@ -48,9 +47,8 @@ export default function RootLayout() { // Previously hardcoded to `darkest` because Play/Pick a Card forced dark regardless // of the Theme setting (amendment 2) — that justification dissolved once issue #173 // themed those screens' chrome too, so every screen now themes and this gap color - // just follows suit, live, via NativeWind's `colorScheme`. - const {colorScheme: resolvedScheme} = useColorScheme() - const isDark = resolvedScheme !== 'light' + // just follows suit, live, via `@/lib/color-scheme`. + const isDark = useColorScheme() === 'dark' const navigator = ( (null) const {height: winHeight} = useWindowDimensions() @@ -134,7 +151,7 @@ export default function LandingScreen() { `style="light"` (white icons) for the always-dark Play/Pick a Card screens, which would go invisible over the near-white `canvasLight` background. */} - + {/* Hidden dev marker (issue #178): a long-press on the wordmark toggles a persisted is_internal flag for this device — the manual counterpart to @@ -157,7 +174,20 @@ export default function LandingScreen() { Change your world,{'\n'}one conversation at a time. diff --git a/apps/mobile/src/app/play/[deck].tsx b/apps/mobile/src/app/play/[deck].tsx index e62bf020..6a3213ac 100644 --- a/apps/mobile/src/app/play/[deck].tsx +++ b/apps/mobile/src/app/play/[deck].tsx @@ -1,10 +1,9 @@ import * as Linking from 'expo-linking' import {useLocalSearchParams, useRouter} from 'expo-router' import {StatusBar} from 'expo-status-bar' -import {useColorScheme} from 'nativewind' import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import type {AppStateStatus, LayoutChangeEvent} from 'react-native' -import {AppState, Text, useWindowDimensions, View} from 'react-native' +import {AppState, StyleSheet, Text, useWindowDimensions, View} from 'react-native' import {Gesture, GestureDetector} from 'react-native-gesture-handler' import Animated, { interpolate, @@ -31,6 +30,7 @@ import {usePlayerChrome} from '@/hooks/use-player-chrome' import {useReviewPrompt} from '@/hooks/use-review-prompt' import {enqueue, flush} from '@/lib/answer-queue' import {send} from '@/lib/answer-transport' +import {useIsDark} from '@/lib/color-scheme' import {incrementCardCount, incrementSessionCount} from '@/lib/app-review' import {getDeviceId} from '@/lib/device-id' import {getStoredGame} from '@/lib/game-store' @@ -40,6 +40,13 @@ import {parsePlayLink} from '@/lib/play-link' import {buildShareCardUrl, buildShareUrl} from '@/lib/share-url' import {getStoredTabletopMode} from '@/lib/tabletop-store' +// react-native-css's safe-area adapter re-exports the platform SafeAreaView, +// which does not consume className (see app/index.tsx). `flex-1 items-center +// justify-center` as explicit styles. +const missingDeckContainerStyle = StyleSheet.create({ + root: {flex: 1, alignItems: 'center', justifyContent: 'center'}, +}).root + const SWIPE_THRESHOLD = 60 // How far off-screen the card travels when a swipe commits (points) const SWIPE_OFF_SCREEN = 400 @@ -114,7 +121,7 @@ export default function PlayScreen() { if (!deck) { return ( - + Deck not found. @@ -184,8 +191,7 @@ const DeckPlayer = ({ // Themed (issue #173): the canvas/chrome around the Question follows the Theme // Display setting like every other screen, and so does the Question's own text // (QuestionText's `themedText`, below) — there's no unthemed surface left here. - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() // the shared headless engine — identical behaviour to the web (ADR-0003) const reducer = useMemo(() => navReducer(questionIds), [questionIds]) diff --git a/apps/mobile/src/components/pick-player.tsx b/apps/mobile/src/components/pick-player.tsx index 235bf209..4b9a7f42 100644 --- a/apps/mobile/src/components/pick-player.tsx +++ b/apps/mobile/src/components/pick-player.tsx @@ -3,7 +3,6 @@ import {Image} from 'expo-image' import {ScreenBackground} from '@/components/screen-background' import {useRouter} from 'expo-router' import {StatusBar} from 'expo-status-bar' -import {useColorScheme} from 'nativewind' import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import type {AppStateStatus, LayoutChangeEvent} from 'react-native' import {AppState, Pressable, StyleSheet, Text, useWindowDimensions, View} from 'react-native' @@ -31,6 +30,7 @@ import {usePlayerChrome} from '@/hooks/use-player-chrome' import {useReviewPrompt} from '@/hooks/use-review-prompt' import {enqueue, flush} from '@/lib/answer-queue' import {send} from '@/lib/answer-transport' +import {useIsDark} from '@/lib/color-scheme' import {incrementCardCount, incrementSessionCount} from '@/lib/app-review' import {getDeviceId} from '@/lib/device-id' import {impact, selection} from '@/lib/haptics' @@ -113,8 +113,7 @@ export const PickPlayer = ({ // Display setting like every other screen now; the deck/card faces below // (CardBack, the revealed face) are untouched — they stay dark in both themes // (amendment 2, docs/design/163-light-mode/proposal.md). - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() const reducer = useMemo(() => pickReducer(questionIds), [questionIds]) const [{nav, phase, dealt}, dispatch] = useReducer(reducer, questionIds, getInitialPick) diff --git a/apps/mobile/src/components/player-bar.tsx b/apps/mobile/src/components/player-bar.tsx index c93228f1..867d42ae 100644 --- a/apps/mobile/src/components/player-bar.tsx +++ b/apps/mobile/src/components/player-bar.tsx @@ -1,10 +1,10 @@ import {Ionicons} from '@expo/vector-icons' -import {useColorScheme} from 'nativewind' import {Text, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {colors} from '@whocards/tokens' import {PressableScale} from '@/components/pressable-scale' +import {useIsDark} from '@/lib/color-scheme' import {impact} from '@/lib/haptics' type IconName = keyof typeof Ionicons.glyphMap @@ -73,8 +73,7 @@ export const PlayerBar = ({ onExit, }: PlayerBarProps) => { const insets = useSafeAreaInsets() - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() const iconColor = isDark ? colors.white : colors.darker return ( diff --git a/apps/mobile/src/components/screen-background.tsx b/apps/mobile/src/components/screen-background.tsx index e16add45..3cc42200 100644 --- a/apps/mobile/src/components/screen-background.tsx +++ b/apps/mobile/src/components/screen-background.tsx @@ -1,11 +1,11 @@ import {Image} from 'expo-image' -import {useColorScheme} from 'nativewind' import type {ReactNode} from 'react' import {StyleSheet, View} from 'react-native' import type {SharedValue} from 'react-native-reanimated' import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {colors} from '@whocards/tokens' +import {useIsDark} from '@/lib/color-scheme' // Rasterized from the website's public/background.svg (the bg-hero texture). // Dark is the original (dark-only) asset; light is a recolored variant for the // Theme Display setting (issue #163) — see docs/design/163-light-mode/proposal.md. @@ -40,8 +40,7 @@ type ScreenBackgroundProps = { * of forcing this whole canvas dark (see `app/play/[deck].tsx`). */ export const ScreenBackground = ({children, textureOpacity, table}: ScreenBackgroundProps) => { - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() const midTable = table && isDark const textureStyle = useAnimatedStyle(() => ({ diff --git a/apps/mobile/src/components/settings-modal.tsx b/apps/mobile/src/components/settings-modal.tsx index f674580e..5db6ebd1 100644 --- a/apps/mobile/src/components/settings-modal.tsx +++ b/apps/mobile/src/components/settings-modal.tsx @@ -1,5 +1,4 @@ import {Ionicons} from '@expo/vector-icons' -import {useColorScheme} from 'nativewind' import {StatusBar} from 'expo-status-bar' import {useEffect, useState} from 'react' import { @@ -29,6 +28,7 @@ import {LanguageSettingsPage} from '@/components/language-settings-page' import {SecondLanguageSettingsPage} from '@/components/second-language-settings-page' import {SettingsSheetHeader} from '@/components/settings-sheet-header' import {ThemeSettingsPage} from '@/components/theme-settings-page' +import {useIsDark} from '@/lib/color-scheme' import {getStoredGame, setStoredGame} from '@/lib/game-store' import {selection} from '@/lib/haptics' import {GAME_CATALOG} from '@/lib/games' @@ -218,8 +218,7 @@ export const SettingsModal = ({ onSelectTheme, }: SettingsModalProps) => { const insets = useSafeAreaInsets() - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() const chevronColor = isDark ? colors.gray.dark : colors.mutedOnLight const reduceMotion = useReducedMotion() diff --git a/apps/mobile/src/components/settings-sheet-header.tsx b/apps/mobile/src/components/settings-sheet-header.tsx index 9c245f57..a038ba45 100644 --- a/apps/mobile/src/components/settings-sheet-header.tsx +++ b/apps/mobile/src/components/settings-sheet-header.tsx @@ -1,8 +1,8 @@ import {Ionicons} from '@expo/vector-icons' -import {useColorScheme} from 'nativewind' import {Pressable, Text, View} from 'react-native' import {colors} from '@whocards/tokens' +import {useIsDark} from '@/lib/color-scheme' type SettingsSheetHeaderProps = { title: string /** @@ -31,8 +31,7 @@ type SettingsSheetHeaderProps = { * this app (the close button this replaces used the same "close" label). */ export const SettingsSheetHeader = ({title, icon, onPress}: SettingsSheetHeaderProps) => { - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() const iconColor = isDark ? colors.white : colors.darker return ( diff --git a/apps/mobile/src/components/share-modal.tsx b/apps/mobile/src/components/share-modal.tsx index e3affe6f..70970d63 100644 --- a/apps/mobile/src/components/share-modal.tsx +++ b/apps/mobile/src/components/share-modal.tsx @@ -1,6 +1,5 @@ import {Ionicons} from '@expo/vector-icons' import {StatusBar} from 'expo-status-bar' -import {useColorScheme} from 'nativewind' import {logWarn} from '@whocards/observability' import type {ShareFormat} from '@whocards/observability/events' import {useCallback, useEffect, useMemo, useState} from 'react' @@ -18,6 +17,7 @@ import {Gesture, GestureDetector, GestureHandlerRootView} from 'react-native-ges import {useSafeAreaInsets} from 'react-native-safe-area-context' import {colors} from '@whocards/tokens' +import {useIsDark} from '@/lib/color-scheme' import {downloadAndShareImage} from '@/lib/share-image' // A downward drag past this many px, or a fling faster than this velocity @@ -145,8 +145,7 @@ export const ShareModal = ({ const [error, setError] = useState(null) // Themed (issue #163, amendment 1): a dark surface in dark mode, the // pre-existing light sheet surface in light mode. - const {colorScheme} = useColorScheme() - const isDark = colorScheme !== 'light' + const isDark = useIsDark() const iconColor = isDark ? colors.white : colors.darker const spinnerColor = isDark ? colors.gray.dark : colors.mutedOnLight diff --git a/apps/mobile/src/components/theme-settings-page.tsx b/apps/mobile/src/components/theme-settings-page.tsx index 31745d8b..e8fb7016 100644 --- a/apps/mobile/src/components/theme-settings-page.tsx +++ b/apps/mobile/src/components/theme-settings-page.tsx @@ -64,7 +64,7 @@ export const ThemeSettingsPage = ({current, onSelect, onBack}: ThemeSettingsPage ) })} - + Matches your device by default. Presentation only — it never changes which Card is drawn or your progress. diff --git a/apps/mobile/src/global.css b/apps/mobile/src/global.css index b5c61c95..0bcfee9f 100644 --- a/apps/mobile/src/global.css +++ b/apps/mobile/src/global.css @@ -1,3 +1,45 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +/* + * NativeWind v5 / Tailwind v4 entry point. Compiled by Metro (see + * metro.config.js) and, in tests, by the same Tailwind pipeline — see + * jest.global-setup.js. + * + * The explicit `@import`s below are what NativeWind v5 recommends over a bare + * `@import "tailwindcss"`: react-native-web needs preflight in its own layer. + */ +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/preflight.css' layer(base); +@import 'tailwindcss/utilities.css'; + +/* NativeWind's own theme: elevation-*, ripple-*, the unitless leading-* override. */ +@import 'nativewind/theme'; + +/* + * The WhoCards design tokens, generated from packages/tokens/src/*.ts. Imported + * after nativewind/theme so the brand faces win over the platform defaults that + * file sets for --font-sans/--font-serif/--font-mono. + */ +@import '@whocards/tokens/theme.css'; + +/* + * Replaces Tailwind v3's `content: ['./src/**‍/*.{ts,tsx}']`. + * + * `__tests__` is excluded: Tailwind emits a utility for every class name it + * sees literally in a scanned file, and tailwind-theme.test.tsx names ~40 of + * them (one per design token) purely to assert they resolve. Scanning it would + * compile all of those into the stylesheet the app ships. The test pipeline + * needs them, so jest.global-setup.js compiles ../jest.global.css instead — + * this file plus the tests — and tailwind-theme.test.tsx fails loudly if that + * re-inclusion ever stops working. + */ +@source './**/*.{ts,tsx}'; +@source not './__tests__/**'; + +:root { + /* + * nativewind/theme sets --font-sans per platform in an *unlayered* `:root`, + * which outranks any `@theme` block no matter the import order. Restate the + * brand face here, unlayered and last, so `font-sans` stays golos-text on + * device. Asserted by src/__tests__/tailwind-theme.test.tsx. + */ + --font-sans: golos-text; +} diff --git a/apps/mobile/src/hooks/use-theme-setting.ts b/apps/mobile/src/hooks/use-theme-setting.ts index 322c82cd..39b33cee 100644 --- a/apps/mobile/src/hooks/use-theme-setting.ts +++ b/apps/mobile/src/hooks/use-theme-setting.ts @@ -1,7 +1,7 @@ -import {colorScheme, useColorScheme} from 'nativewind' import {useCallback, useEffect, useState} from 'react' import {EVENTS, track} from '@whocards/observability/events' +import {setColorScheme, useColorScheme} from '@/lib/color-scheme' import {getStoredTheme, setStoredTheme} from '@/lib/theme-store' import type {ThemeSetting} from '@/lib/theme-store' @@ -10,12 +10,9 @@ import type {ThemeSetting} from '@/lib/theme-store' * never affects which Card is drawn or whose progress is remembered): * System-follow by default, with a manual Light/Dark override. * - * NativeWind already follows the OS Appearance out of the box (its - * `colorScheme` observable falls back to `Appearance.getColorScheme()` - * whenever no explicit override has been set) — this hook's job is just to - * restore a previously-chosen manual override on boot and to apply/persist a - * new choice, via NativeWind's own `colorScheme.set()` / `useColorScheme()` - * rather than a separate theming mechanism. + * The scheme itself lives in `@/lib/color-scheme`, which follows the OS + * Appearance out of the box — this hook's job is just to restore a + * previously-chosen manual override on boot and to apply/persist a new choice. * * `resolvedScheme` is the live, effective 'light' | 'dark' for the rare * JS-level conditional (an `Ionicons` color, a `StatusBar` style, which @@ -26,28 +23,21 @@ import type {ThemeSetting} from '@/lib/theme-store' */ export const useThemeSetting = () => { const [theme, setTheme] = useState('system') - const {colorScheme: resolvedScheme} = useColorScheme() + const resolvedScheme = useColorScheme() useEffect(() => { void getStoredTheme().then((stored) => { - colorScheme.set(stored) + setColorScheme(stored) setTheme(stored) }) }, []) const select = useCallback((next: ThemeSetting) => { - colorScheme.set(next) + setColorScheme(next) setTheme(next) void setStoredTheme(next) track({name: EVENTS.THEME_CHANGED, props: {theme: next}}) }, []) - return { - theme, - // Dark-first fallback for the brief window before the boot effect above - // resolves — matches the app's historical (dark-only) appearance rather - // than assuming light. - resolvedScheme: resolvedScheme ?? 'dark', - select, - } + return {theme, resolvedScheme, select} } diff --git a/apps/mobile/src/lib/color-scheme.ts b/apps/mobile/src/lib/color-scheme.ts new file mode 100644 index 00000000..f3846a9a --- /dev/null +++ b/apps/mobile/src/lib/color-scheme.ts @@ -0,0 +1,74 @@ +import {useSyncExternalStore} from 'react' +import {Appearance} from 'react-native' +import {colorScheme} from 'react-native-css' + +import type {ThemeSetting} from './theme-store' + +/** + * The app's single source of truth for "is this screen light or dark right now". + * + * NativeWind v4 exported a `colorScheme` observable whose setter took the full + * `'system' | 'light' | 'dark'` triple. v5 dropped that export: the underlying + * `react-native-css` observable is two-valued, seeded from + * `Appearance.getColorScheme()` and kept in sync with it by a change listener. + * The "system" mode is now the app's job, and this module is it. + * + * It deliberately does *not* call `Appearance.setColorScheme()`. That API + * overrides what `Appearance.getColorScheme()` reports, so once used the real + * OS preference becomes unreadable and "System" could never resolve back to it. + * Writing the `react-native-css` observable instead drives every `dark:` class, + * and `useColorScheme()` below reads the same value, so the classes and the + * handful of JS-level conditionals (an Ionicons colour, a StatusBar style, + * which Image source to load) can never disagree. + */ +let setting: ThemeSetting = 'system' + +const listeners = new Set<() => void>() + +/** + * Dark-first fallback for the (rare) platforms that report no OS preference at + * all — matches the app's historical, dark-only appearance rather than + * assuming light. + */ +const resolve = () => (setting === 'system' ? (Appearance.getColorScheme() ?? 'dark') : setting) + +const apply = () => { + colorScheme.set(resolve()) + for (const listener of listeners) listener() +} + +// Seed the observable immediately: `dark:` variants compare against its raw +// value, so leaving it null (which is what a platform with no preference +// reports) would make both `dark:` and its light-mode counterpart miss. +apply() + +/** + * `react-native-css` mirrors OS appearance changes straight into its observable, + * which would silently drop a manual override. Re-assert ours afterwards — this + * listener is registered second, because importing this module runs theirs first. + */ +Appearance.addChangeListener(() => apply()) + +/** Applies (and remembers) a Theme setting. Persistence lives in theme-store.ts. */ +export const setColorScheme = (next: ThemeSetting): void => { + setting = next + apply() +} + +/** The effective scheme right now. Never null. */ +export const getColorScheme = () => colorScheme.get() ?? resolve() + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** + * Re-renders on every scheme change, whether from the OS or from Settings. + * The third argument is the server snapshot: app.json declares `web.output: + * "static"`, and React rejects a server render of useSyncExternalStore without one. + */ +export const useColorScheme = () => useSyncExternalStore(subscribe, getColorScheme, getColorScheme) + +/** Shorthand for the overwhelmingly common `useColorScheme() === 'dark'`. */ +export const useIsDark = () => useColorScheme() === 'dark' diff --git a/apps/mobile/src/test-utils/resolved-style.ts b/apps/mobile/src/test-utils/resolved-style.ts new file mode 100644 index 00000000..f51d8d0f --- /dev/null +++ b/apps/mobile/src/test-utils/resolved-style.ts @@ -0,0 +1,53 @@ +/** + * Helpers for asserting on the styles NativeWind actually resolved a + * `className` into. + * + * Under NativeWind v5 a `className` never reaches the rendered element: the + * component wrapper compiles it away and emits a `style` prop instead. So + * `expect(node.props.className).toBe('text-white')` — which is what these tests + * used to do — can only ever assert that a string was passed *in*, and would + * keep passing even if the whole CSS pipeline stopped producing styles. Reading + * the resolved `style` back asserts the thing that actually reaches the screen: + * that `text-white` is `colors.white`, that the Tailwind theme was built from + * @whocards/tokens, and that class resolution is running at all. + */ + +type StyledNode = {props: {style?: unknown}} + +/** + * The single flattened style object behind a node's `style` prop. NativeWind + * emits an array (one entry per contributing rule, in cascade order), which + * React Native flattens last-wins. + */ +export const resolvedStyle = (node: StyledNode): Record => { + const merged: Record = {} + for (const entry of [node.props.style].flat(Number.POSITIVE_INFINITY)) { + if (entry && typeof entry === 'object') Object.assign(merged, entry) + } + return merged +} + +/** + * A token colour with Tailwind's `/NN` opacity modifier applied, in the 8-digit + * hex form lightningcss serialises to — `withOpacity(colors.white, 70)` is what + * `text-white/70` should resolve to. + * + * The alpha byte is `round(f32(percent / 100) * 255)`: lightningcss holds alpha + * as a 32-bit float, so `70%` is 0.69999998 and rounds *down* to 178 (`b2`) + * rather than the 179 an exact 0.7 would give. + */ +export const withOpacity = (hex: string, percent: number): string => + hex + + Math.round(Math.fround(percent / 100) * 255) + .toString(16) + .padStart(2, '0') + +/** + * Tailwind's root font size, in the px React Native works in. Tailwind emits + * spacing/radius tokens in `rem`; react-native-css inlines them at compile time + * against this multiplier, so `rounded-2.5xl` (1.25rem) is 17.5. + */ +export const REM_PX = 14 + +/** `rem('1.125rem')` → 15.75 — a rem-valued token as the px it compiles to. */ +export const rem = (value: string): number => Number.parseFloat(value) * REM_PX diff --git a/apps/mobile/src/test-utils/ui-snapshot.ts b/apps/mobile/src/test-utils/ui-snapshot.ts index ae6d6384..637df925 100644 --- a/apps/mobile/src/test-utils/ui-snapshot.ts +++ b/apps/mobile/src/test-utils/ui-snapshot.ts @@ -11,7 +11,8 @@ * - the host component type (`View`, `Text`, `Image`, `Modal`, …) * - the FLATTENED resolved style (see `flattenStyle`) — one line per property, * alphabetically, so a diff points straight at the property that changed - * - the `className` that NativeWind resolves into style at runtime + * - a `className` only when a host mock still exposes it (NativeWind v5 + * normally compiles it away and emits the resolved `style` instead) * - rendered text content * - the accessibility props that have a presentational effect: * `accessibilityRole`, `accessibilityLabel`, `accessibilityState` @@ -34,13 +35,12 @@ * 1. The native side. This is the JS half of the render only; what the native * layer does with these props (Yoga's actual layout maths, text shaping, * shadow rasterisation) is invisible here. A clean run is evidence that the - * JS render is unchanged, not that the screen is — that still needs an - * on-device / Maestro pass. - * 2. Tailwind classes resolved to style. Jest has no Metro, so no compiled - * NativeWind CSS is ever registered and every `className` resolves to an - * empty style; the class strings are recorded verbatim instead. So the - * styles below are the inline / `StyleSheet` ones a component passes - * directly, and `className` is the unresolved rest. + * JS render matches this baseline, not that the screen is identical — that + * still needs an on-device / Maestro comparison. + * 2. Native layout and rasterization. Jest compiles and injects the app's + * stylesheet, so Tailwind classes are represented by their resolved styles; + * Yoga layout, text shaping, shadows, and platform rendering still need a + * native comparison. * * Usage: * @@ -125,7 +125,7 @@ const INDENT = ' ' /** * Appends one node's lines. The format is indentation-structured: * `` opens an element (no closing tag — indentation is structure) - * `~ className` is the element's Tailwind classes, unresolved (see above) + * `~ className` is a class string exposed by a host mock (usually absent in v5) * `| key: value` is one resolved style property of the element above it * `"…"` is rendered text */ diff --git a/apps/mobile/tailwind.config.ts b/apps/mobile/tailwind.config.ts deleted file mode 100644 index deba80ee..00000000 --- a/apps/mobile/tailwind.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Loaded by Tailwind/NativeWind via jiti (which transpiles this and the imported -// @whocards/tokens source on the fly). Excluded from `tsc` typecheck because the -// nativewind preset ships loose types — see tsconfig.json "exclude". -import {tailwindPreset} from '@whocards/tokens/tailwind-preset' -import nativewindPreset from 'nativewind/preset' - -const config = { - content: ['./src/**/*.{ts,tsx}'], - presets: [nativewindPreset, tailwindPreset], -} - -export default config diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 54f8aa0f..183e20cc 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -9,5 +9,5 @@ "types": ["jest"] }, "include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts", ".expo/types/**/*.ts", "expo-env.d.ts"], - "exclude": ["node_modules", "tailwind.config.ts"] + "exclude": ["node_modules"] } diff --git a/apps/website/package.json b/apps/website/package.json index 2dffb880..68f52da2 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -28,6 +28,7 @@ "@whocards/decks": "workspace:*", "@whocards/emails": "workspace:*", "@whocards/observability": "workspace:*", + "@whocards/tokens": "workspace:*", "@trpc/client": "catalog:", "@trpc/server": "catalog:", "@astrojs/mdx": "^8.0.0", @@ -72,7 +73,7 @@ "split-text-js": "^1.0.3", "svix": "^1.96.0", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.1", + "tailwindcss": "^4.3.3", "wawoff2": "^2.0.1", "zod": "catalog:" }, diff --git a/apps/website/src/styles/base.css b/apps/website/src/styles/base.css index bf3a2018..ece9b7c6 100644 --- a/apps/website/src/styles/base.css +++ b/apps/website/src/styles/base.css @@ -1,4 +1,9 @@ @import 'tailwindcss'; +/* + * The design tokens, shared with the app: a Tailwind v4 `@theme` block rendered + * from packages/tokens/src/*.ts. Website-only theme values stay below. + */ +@import '@whocards/tokens/theme.css'; @plugin "daisyui" { themes: false; logs: false; @@ -6,49 +11,8 @@ @plugin "@tailwindcss/typography"; @theme { - /* Spacing */ - --spacing-4\.5: 1.125rem; - --spacing-7\.5: 1.875rem; - - /* Border radius */ - --radius-2\.5xl: 1.25rem; - - /* Colors */ - --color-yellow-100: #7e7552; - --color-yellow-300: #ffe37e; - --color-yellow-400: #f9d75f; - --color-yellow-500: #f6c944; - - --color-gray: #65636e; - --color-gray-lighter: #dcdee9; - --color-gray-light: #474a69; - --color-gray-dark: #9698af; - - --color-dark: #262432; - --color-darker: #111516; - --color-darkest: #08001a; - --color-background: #0d051f; - --color-white: #f5f5f5; - --color-red: #ee1e23; - - --color-primary-light: #f9d75f; - --color-primary-dark: #c058d2; - - /* Background images */ + /* Background images — the hero art is website-only */ --background-image-hero: url(/background.svg); - --background-image-gradient-primary: linear-gradient(to bottom left, #c058d2 25%, #f9d75f 92%); - - /* Font families */ - --font-sans: golos-text, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-title: aptly, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-chinese: noto-sans-chinese, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-hebrew: noto-sans-hebrew, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-japanese: noto-sans-japanese, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* Grid template rows */ --grid-template-rows-question: 1fr min-content; diff --git a/docs/design/163-light-mode/proposal.md b/docs/design/163-light-mode/proposal.md index 6c0912cd..ad4aeb33 100644 --- a/docs/design/163-light-mode/proposal.md +++ b/docs/design/163-light-mode/proposal.md @@ -134,7 +134,7 @@ tokens** (`canvasLight`, `mutedOnLight`, `accentOnLight`, `accentOnDark`, `error `dark`, `darkest`, `primary-dark`, `red` all keep their current values and current call sites unchanged. Every themed surface picks between an existing token and a new one via NativeWind's `dark:` variant (e.g. `className="bg-white dark:bg-dark"`) or a small conditional in JS -(`useColorScheme()` from `nativewind`) for props that take a raw color (`Ionicons` `color`, +(~~`useColorScheme()` from `nativewind`~~ — now `useIsDark()` from `@/lib/color-scheme`, see §Theming mechanism) for props that take a raw color (`Ionicons` `color`, `StatusBar` `style`, `Image` `source`). ## Wordmark & the maze texture @@ -316,7 +316,8 @@ Dark — not a plain two-way toggle. remembered" — not a Game, and not a decision that lives in the play engine. - Persisted device-locally via `lib/theme-store.ts` (mirrors `lib/game-store.ts`'s pattern: a global `whocards-theme` AsyncStorage key, in-memory cache, default fallback), read on boot in - `_layout.tsx` and applied via NativeWind's `colorScheme.set()`. + `_layout.tsx` and applied via `setColorScheme()` (`lib/color-scheme.ts` — originally NativeWind's + own `colorScheme.set()`; see [§ Theming mechanism](#theming-mechanism)). **Where it lives:** a new **"Theme"** chip, styled identically to the existing "Game: Global Game" quiet outline chip on the Library screen, opening a page-sheet in the same house style as @@ -330,24 +331,28 @@ see [§ Overlapping in-flight work](#overlapping-in-flight-work-and-how-it-was-r ## Theming mechanism -**NativeWind's built-in `dark:` variant + `colorScheme` API** — no new theming library, no -Tailwind config changes needed. - -- `apps/mobile/tailwind.config.ts` needed no changes: NativeWind v4 wires the `dark:` class - variant to a runtime `colorScheme` observable automatically. Out of the box, with nothing - called, it already follows `Appearance` (confirmed by reading - `react-native-css-interop`'s `appearance-observables.js` — `systemColorScheme` tracks - `Appearance.getColorScheme()` and `colorScheme.get()` falls back to it whenever no explicit - override has been set). "System-follow" is the _default_ behavior, not something this - implementation had to build. -- `colorScheme.set('light' | 'dark' | 'system')` (imperative, from `'nativewind'`) sets or clears - a manual override. `useColorScheme()` (also from `'nativewind'`) returns the live resolved - `{colorScheme: 'light' | 'dark'}` for the rare JS-level conditional (an `Ionicons` `color` prop, - a `StatusBar` `style`, picking which `Image` source to `require`) that can't be expressed as a - `dark:` class. +**NativeWind's built-in `dark:` variant + a colour-scheme observable** — no new theming library, +no Tailwind config changes needed. + +- ~~`apps/mobile/tailwind.config.ts` needed no changes~~ _(historical: there is no + `tailwind.config.ts` any more — the NativeWind v5 / Tailwind v4 upgrade moved the theme into + CSS, `apps/mobile/src/global.css`, generated from `@whocards/tokens`.)_ NativeWind wires the + `dark:` class variant to a runtime colour-scheme observable automatically, and out of the box, + with nothing called, it already follows `Appearance`. "System-follow" is the _default_ + behavior, not something this implementation had to build. +- ~~`colorScheme.set('light' | 'dark' | 'system')` (imperative, from `'nativewind'`)~~ + _(historical: v5 dropped that export. The underlying `react-native-css` observable is + two-valued — light or dark, seeded from `Appearance` — so "system" became the app's job, and + `apps/mobile/src/lib/color-scheme.ts` is now the one module that owns it.)_ + `setColorScheme('light' | 'dark' | 'system')` sets or clears the manual override, and + `useColorScheme()` (both from `@/lib/color-scheme`) returns the live resolved + `'light' | 'dark'` for the rare JS-level conditional (an `Ionicons` `color` prop, a `StatusBar` + `style`, picking which `Image` source to `require`) that can't be expressed as a `dark:` class. + Deliberately _not_ `Appearance.setColorScheme()`: that overrides what `Appearance` reports, so + once used the real OS preference is unreadable and "System" could never resolve back to it. - `apps/mobile/src/hooks/use-theme-setting.ts` is the one new piece of glue: on mount, reads - `getStoredTheme()` and calls `colorScheme.set()` with it; exposes a `select(next)` that updates - the colorScheme, persists it, and fires the `theme_changed` observability event. + `getStoredTheme()` and calls `setColorScheme()` with it; exposes a `select(next)` that updates + the colour scheme, persists it, and fires the `theme_changed` observability event. - **Status bar handling, deliberately NOT made globally theme-aware:** `_layout.tsx`'s root `` default is untouched. Play and Pick a Card are _always_ dark (amendment 2) and need light status-bar icons regardless of the Theme setting — if the root diff --git a/packages/tokens/package.json b/packages/tokens/package.json index ae749980..e3d87499 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -5,13 +5,15 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./tailwind-preset": "./src/tailwind-preset.ts" + "./theme.css": "./theme.css" }, "scripts": { + "generate:theme": "GENERATE_THEME_CSS=1 vitest run src/theme-css.test.ts", "typecheck": "tsc --noEmit", "test": "vitest run" }, "devDependencies": { + "@types/node": "catalog:", "@whocards/typescript-config": "workspace:*", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/tokens/src/tailwind-preset.ts b/packages/tokens/src/tailwind-preset.ts deleted file mode 100644 index 20275fd6..00000000 --- a/packages/tokens/src/tailwind-preset.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {colors} from './colors' -import {gradients} from './gradients' -import {radius, spacing} from './spacing' -import {fonts} from './typography' - -/** - * Tailwind `theme.extend` shaped from the tokens. Consumed by mobile's NativeWind - * config and available to regenerate the web's `@theme` block, so utility classes - * (`bg-yellow-400`, `text-primary-light`, `rounded-2.5xl`, `font-title`, …) resolve - * the same on every surface. - */ -export const tailwindTheme = { - extend: { - colors, - spacing, - borderRadius: radius, - fontFamily: { - sans: [...fonts.sans.stack], - title: [...fonts.title.stack], - chinese: [...fonts.chinese.stack], - hebrew: [...fonts.hebrew.stack], - japanese: [...fonts.japanese.stack], - }, - backgroundImage: { - 'gradient-primary': gradients.primary, - }, - }, -} as const - -/** A drop-in Tailwind preset: `presets: [tailwindPreset]`. */ -export const tailwindPreset = {theme: tailwindTheme} as const - -export type TailwindPreset = typeof tailwindPreset diff --git a/packages/tokens/src/theme-css.test.ts b/packages/tokens/src/theme-css.test.ts new file mode 100644 index 00000000..6bc32fe0 --- /dev/null +++ b/packages/tokens/src/theme-css.test.ts @@ -0,0 +1,32 @@ +/** + * `theme.css` is a generated artifact — the TypeScript tokens rendered as a + * Tailwind v4 `@theme` block (see theme-css.ts for why it has to be rendered at + * all). This file is both the drift guard and the generator: + * + * - normally it asserts the committed file matches the renderer, so a token + * change that forgets to regenerate fails CI; + * - with `GENERATE_THEME_CSS=1` (`pnpm --filter @whocards/tokens + * generate:theme`) it writes the file instead. + * + * Deliberately one file rather than a script plus a separate check: a + * standalone Node script can't load this package's extensionless TypeScript + * imports without adding a runner dependency, and a generator that shares no + * code with its own check can drift from it. + */ +import {readFileSync, writeFileSync} from 'node:fs' +import {fileURLToPath} from 'node:url' +import {describe, expect, it} from 'vitest' + +import {renderThemeCss} from './theme-css' + +const themeCssPath = fileURLToPath(new URL('../theme.css', import.meta.url)) + +describe('theme.css', () => { + it('matches the tokens it is generated from', () => { + const rendered = renderThemeCss() + + if (process.env.GENERATE_THEME_CSS) writeFileSync(themeCssPath, rendered) + + expect(readFileSync(themeCssPath, 'utf8')).toBe(rendered) + }) +}) diff --git a/packages/tokens/src/theme-css.ts b/packages/tokens/src/theme-css.ts new file mode 100644 index 00000000..3383dbd4 --- /dev/null +++ b/packages/tokens/src/theme-css.ts @@ -0,0 +1,87 @@ +/** + * Renders the design tokens as a Tailwind v4 `@theme` block. + * + * Tailwind v4 is CSS-first: a theme is CSS custom properties in a `@theme` + * block, not a JS `theme.extend` object, and there is no supported way to hand + * it a plain JS object (`@config` only loads a whole *v3-shaped* config file). + * So the tokens — still authored in TypeScript, still the single source of + * truth — are *rendered* to CSS here, and the rendered file (`theme.css`) is + * committed next to them and imported by both apps. `theme-css.test.ts` fails + * if the committed file ever drifts from this renderer. + * + * That drift check is byte-exact, which is why `.oxfmtrc.json` lists + * `packages/tokens/theme.css` under `ignorePatterns`: oxfmt rewraps the five + * long `--font-*` declarations this renderer emits on one line each, and a + * formatted `theme.css` can never equal `renderThemeCss()` again — the check + * would fail on a clean checkout and stay failing. Formatting the *generated* + * file is meaningless anyway; format this renderer instead, and regenerate. + * + * Keep in sync with the Tailwind v4 theme namespaces: + * https://tailwindcss.com/docs/theme#theme-variable-namespaces + */ +import {colors} from './colors' +import {gradients} from './gradients' +import {radius, spacing} from './spacing' +import {fonts} from './typography' + +/** `4.5` → `4\.5` — a `.` is a class separator in CSS, so it must be escaped. */ +const escapeName = (name: string) => name.replace(/\./g, '\\.') + +const line = (namespace: string, name: string, value: string) => + ` --${namespace}-${escapeName(name)}: ${value};` + +/** + * Flattens one colour token into its `--color-*` declarations. A nested group + * renders `group-shade` per shade, with a `DEFAULT` shade rendering as the bare + * group name (`--color-gray`), matching Tailwind's own `DEFAULT` convention. + */ +const colorLines = (name: string, value: string | Record): string[] => { + if (typeof value === 'string') return [line('color', name, value)] + return Object.entries(value).map(([shade, hex]) => + line('color', shade === 'DEFAULT' ? name : `${name}-${shade}`, hex) + ) +} + +const section = (title: string, lines: string[]) => [` /* ${title} */`, ...lines].join('\n') + +/** The tokens as a Tailwind v4 `@theme` block. The body of `theme.css`. */ +export const renderThemeCss = (): string => { + const body = [ + section( + 'Spacing — extra steps beyond Tailwind’s default scale', + Object.entries(spacing).map(([name, value]) => line('spacing', name, value)) + ), + section( + 'Border radius', + Object.entries(radius).map(([name, value]) => line('radius', name, value)) + ), + section( + 'Colours', + Object.entries(colors).flatMap(([name, value]) => colorLines(name, value)) + ), + section( + 'Font families', + Object.entries(fonts).map(([name, font]) => line('font', name, font.stack.join(', '))) + ), + section( + 'Background images', + Object.entries(gradients).map(([name, value]) => + line('background-image', `gradient-${name}`, value) + ) + ), + ].join('\n\n') + + return `/* + * GENERATED FILE — do not edit by hand. + * Source of truth: packages/tokens/src/*.ts, rendered by src/theme-css.ts. + * Regenerate with \`pnpm --filter @whocards/tokens generate:theme\`. + * + * Imported by every surface that uses Tailwind, so \`bg-yellow-400\`, + * \`text-mutedOnLight\`, \`rounded-2.5xl\`, \`font-title\`, … resolve to the same + * value on the website (Tailwind v4) and in the app (NativeWind v5). + */ +@theme { +${body} +} +` +} diff --git a/packages/tokens/src/tokens.test.ts b/packages/tokens/src/tokens.test.ts index 7bd15b7c..8a86e0af 100644 --- a/packages/tokens/src/tokens.test.ts +++ b/packages/tokens/src/tokens.test.ts @@ -1,7 +1,6 @@ import {describe, expect, it} from 'vitest' import {colors, fonts, gradients, radius, spacing, tokens} from './index' -import {tailwindTheme} from './tailwind-preset' /** * `Object.keys` always widens to `string[]`; every key set this test walks is @@ -39,16 +38,8 @@ describe('gradients', () => { }) }) -describe('aggregate + tailwind preset', () => { +describe('aggregate', () => { it('groups every token family', () => { expect(tokens).toMatchObject({colors, spacing, radius, fonts, gradients}) }) - - it('shapes a tailwind theme.extend that mirrors the tokens', () => { - expect(tailwindTheme.extend.colors).toBe(colors) - expect(tailwindTheme.extend.borderRadius).toBe(radius) - expect(tailwindTheme.extend.spacing).toBe(spacing) - expect(tailwindTheme.extend.fontFamily.title[0]).toBe('aptly') - expect(tailwindTheme.extend.backgroundImage['gradient-primary']).toBe(gradients.primary) - }) }) diff --git a/packages/tokens/theme.css b/packages/tokens/theme.css new file mode 100644 index 00000000..b2f2eeb6 --- /dev/null +++ b/packages/tokens/theme.css @@ -0,0 +1,51 @@ +/* + * GENERATED FILE — do not edit by hand. + * Source of truth: packages/tokens/src/*.ts, rendered by src/theme-css.ts. + * Regenerate with `pnpm --filter @whocards/tokens generate:theme`. + * + * Imported by every surface that uses Tailwind, so `bg-yellow-400`, + * `text-mutedOnLight`, `rounded-2.5xl`, `font-title`, … resolve to the same + * value on the website (Tailwind v4) and in the app (NativeWind v5). + */ +@theme { + /* Spacing — extra steps beyond Tailwind’s default scale */ + --spacing-4\.5: 1.125rem; + --spacing-7\.5: 1.875rem; + + /* Border radius */ + --radius-2\.5xl: 1.25rem; + + /* Colours */ + --color-yellow-100: #7e7552; + --color-yellow-300: #ffe37e; + --color-yellow-400: #f9d75f; + --color-yellow-500: #f6c944; + --color-gray: #65636e; + --color-gray-lighter: #dcdee9; + --color-gray-light: #474a69; + --color-gray-dark: #9698af; + --color-primary-light: #f9d75f; + --color-primary-dark: #c058d2; + --color-dark: #262432; + --color-darker: #111516; + --color-darkest: #08001a; + --color-background: #0d051f; + --color-white: #f5f5f5; + --color-red: #ee1e23; + --color-canvasLight: #efe7f7; + --color-mutedOnLight: #6b6d82; + --color-accentOnLight: #9a3aac; + --color-errorOnLight: #c9151a; + --color-accentOnDark: #d485e2; + --color-errorOnDark: #ff5b5f; + + /* Font families */ + --font-sans: golos-text, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-title: aptly, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-chinese: noto-sans-chinese, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-hebrew: noto-sans-hebrew, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-japanese: noto-sans-japanese, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + /* Background images */ + --background-image-gradient-primary: linear-gradient(to bottom left, #c058d2 25%, #f9d75f 92%); +} diff --git a/packages/tokens/tsconfig.json b/packages/tokens/tsconfig.json index 2944ed3a..23b73c69 100644 --- a/packages/tokens/tsconfig.json +++ b/packages/tokens/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@whocards/typescript-config/base.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "types": ["node"] }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4266dc30..0a1c41de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ catalogs: specifier: ^4.1.13 version: 4.4.3 +overrides: + '@expo/metro-config>lightningcss': 1.30.1 + importers: .: @@ -323,8 +326,8 @@ importers: specifier: ~57.0.21 version: 57.0.21(expo-dev-client@57.0.18(expo@57.0.20)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8)))(expo@57.0.20)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) nativewind: - specifier: ^4.1.23 - version: 4.2.6(04ea67d64433bb4fe6d83655c1150d1d) + specifier: 5.0.0-preview.4 + version: 5.0.0-preview.4(react-native-css@3.0.7(@expo/metro-config@57.0.12(expo@57.0.20)(typescript@6.0.3))(lightningcss@1.30.1)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(tailwindcss@4.3.3) posthog-react-native: specifier: ^4.67.0 version: 4.67.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8)))(expo-application@57.0.2(expo@57.0.20))(expo-device@57.0.1(expo@57.0.20))(expo-file-system@57.0.6(expo@57.0.20)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8)))(expo-localization@57.0.1(expo@57.0.20)(react@19.2.8))(react-native-safe-area-context@5.9.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)) @@ -337,9 +340,9 @@ importers: react-native: specifier: 0.86.3 version: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - react-native-css-interop: - specifier: 0.2.6 - version: 0.2.6(04ea67d64433bb4fe6d83655c1150d1d) + react-native-css: + specifier: ^3.0.7 + version: 3.0.7(@expo/metro-config@57.0.12(expo@57.0.20)(typescript@6.0.3))(lightningcss@1.30.1)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) react-native-gesture-handler: specifier: ~3.2.1 version: 3.2.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) @@ -358,9 +361,6 @@ importers: react-native-worklets: specifier: 0.12.1 version: 0.12.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) - tailwindcss: - specifier: ^3.4.17 - version: 3.4.19(tsx@4.23.13)(yaml@2.9.0) zod: specifier: 'catalog:' version: 4.4.3 @@ -368,6 +368,9 @@ importers: '@babel/plugin-transform-react-jsx': specifier: ^7.29.7 version: 7.29.7(@babel/core@7.29.7) + '@tailwindcss/postcss': + specifier: ^4.3.3 + version: 4.3.3 '@testing-library/react-native': specifier: ^14.0.1 version: 14.0.1(jest@30.5.1(@types/node@26.4.1))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(test-renderer@1.2.0(@types/react@19.2.18)(react@19.2.8)) @@ -389,6 +392,15 @@ importers: jest-expo: specifier: ~57.0.5 version: 57.0.5(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(expo@57.0.20)(jest@30.5.1(@types/node@26.4.1))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + lightningcss: + specifier: 1.30.1 + version: 1.30.1 + postcss: + specifier: ^8.5.28 + version: 8.5.28 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ~6.0.3 version: 6.0.3 @@ -445,7 +457,7 @@ importers: version: 0.13.11(typescript@5.9.3)(zod@4.4.3) '@tailwindcss/typography': specifier: ^0.5.20 - version: 0.5.20(tailwindcss@4.3.2) + version: 0.5.20(tailwindcss@4.3.3) '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.2(@types/node@24.13.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) @@ -476,6 +488,9 @@ importers: '@whocards/observability': specifier: workspace:* version: link:../../packages/observability + '@whocards/tokens': + specifier: workspace:* + version: link:../../packages/tokens astro: specifier: ^7.3.1 version: 7.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@netlify/blobs@10.7.13)(@types/node@24.13.2)(db0@0.3.4(@electric-sql/pglite@0.5.8)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(pg@8.23.0)(postgres@3.4.9)))(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) @@ -543,8 +558,8 @@ importers: specifier: ^3.6.0 version: 3.6.0 tailwindcss: - specifier: ^4.3.1 - version: 4.3.2 + specifier: ^4.3.3 + version: 4.3.3 wawoff2: specifier: ^2.0.1 version: 2.0.1 @@ -667,6 +682,9 @@ importers: packages/tokens: devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.2 '@whocards/typescript-config': specifier: workspace:* version: link:../../tooling/typescript @@ -675,7 +693,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@26.4.1)(jsdom@20.0.3)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(jsdom@20.0.3)(vite@8.2.2(@types/node@24.13.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) tooling/typescript: {} @@ -5157,6 +5175,9 @@ packages: resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} engines: {node: '>= 20'} + '@tailwindcss/postcss@4.3.3': + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@tailwindcss/typography@0.5.20': resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} peerDependencies: @@ -5410,6 +5431,9 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -5447,6 +5471,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} @@ -6021,9 +6048,6 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -6193,6 +6217,10 @@ packages: babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421: + resolution: {integrity: sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw==} + deprecated: Wrong version name was published + babel-plugin-react-native-web@0.21.2: resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} @@ -6580,10 +6608,6 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -6788,6 +6812,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colorjs.io@0.6.0-alpha.1: + resolution: {integrity: sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -6814,10 +6841,6 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -7232,11 +7255,6 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -7306,9 +7324,6 @@ packages: devtools-protocol@0.0.1666840: resolution: {integrity: sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==} - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7321,9 +7336,6 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dnssd-advertise@1.1.6: resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} @@ -8402,10 +8414,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -9189,10 +9197,6 @@ packages: jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -9355,8 +9359,8 @@ packages: cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.27.0: - resolution: {integrity: sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ==} + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] @@ -9373,8 +9377,8 @@ packages: cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.27.0: - resolution: {integrity: sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg==} + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] @@ -9391,8 +9395,8 @@ packages: cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.27.0: - resolution: {integrity: sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA==} + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] @@ -9409,8 +9413,8 @@ packages: cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.27.0: - resolution: {integrity: sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA==} + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] @@ -9427,8 +9431,8 @@ packages: cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.27.0: - resolution: {integrity: sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A==} + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] @@ -9448,8 +9452,8 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.27.0: - resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] @@ -9469,8 +9473,8 @@ packages: os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.27.0: - resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] @@ -9490,8 +9494,8 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.27.0: - resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] @@ -9511,8 +9515,8 @@ packages: os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.27.0: - resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] @@ -9529,8 +9533,8 @@ packages: cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.27.0: - resolution: {integrity: sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw==} + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -9547,8 +9551,8 @@ packages: cpu: [x64] os: [win32] - lightningcss@1.27.0: - resolution: {integrity: sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==} + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} lightningcss@1.32.0: @@ -9941,9 +9945,6 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9970,11 +9971,12 @@ packages: engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true - nativewind@4.2.6: - resolution: {integrity: sha512-kLPRH3Yh/hhIFufXzUZDe39fuK471GwaPBCqYNgcRygILr+obV+wFfDmG+zaJnniVUFFQzYOTCEldem6XktZ+A==} - engines: {node: '>=16'} + nativewind@5.0.0-preview.4: + resolution: {integrity: sha512-eLsFGczxIsWADoD18EbEyt01aWgw4uP1/t/O7JIsp/OEbSgVYVobW97e2DDTOxxiiqSu0RexC1KSo5d7tes9uQ==} + engines: {node: '>=20'} peerDependencies: - tailwindcss: '>3.3.0' + react-native-css: ^3.0.1 + tailwindcss: '>4.1.11' natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -10168,10 +10170,6 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -10562,10 +10560,6 @@ packages: engines: {node: '>=0.10'} hasBin: true - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -10602,50 +10596,10 @@ packages: postal-mime@2.7.5: resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss-selector-parser@6.1.4: - resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} - engines: {node: '>=4'} - postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -10986,21 +10940,13 @@ packages: react-is@19.2.8: resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} - react-native-css-interop@0.2.6: - resolution: {integrity: sha512-uXad6EWqufupnRjrba2De30tiAx26kLEcwSV3eSLVBwpfYeOnrpDc9k6kHk6SBNbmHHlE5sc9vsfKdVRS9msYg==} - engines: {node: '>=18'} + react-native-css@3.0.7: + resolution: {integrity: sha512-xsU9T5MleHI/DDmY72A7ZikbkDfDNkMrAEichOJW2bBUMxPzAJPrFk9ys4cXYk4gLFaR11y23EccOJ+JT6Iusw==} peerDependencies: - react: '>=18' - react-native: '*' - react-native-reanimated: '>=3.6.2' - react-native-safe-area-context: '*' - react-native-svg: '*' - tailwindcss: ~3 - peerDependenciesMeta: - react-native-safe-area-context: - optional: true - react-native-svg: - optional: true + '@expo/metro-config': '>=54' + lightningcss: '>=1.27.0' + react: '>=19' + react-native: '>=0.81' react-native-drawer-layout@4.2.10: resolution: {integrity: sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==} @@ -11126,9 +11072,6 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - read-package-up@11.0.0: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} @@ -11840,11 +11783,6 @@ packages: styleq@0.1.3: resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - suf-log@2.5.3: resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} @@ -11890,13 +11828,11 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@3.4.19: - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true - - tailwindcss@4.3.2: - resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tailwindcss-safe-area@1.3.0: + resolution: {integrity: sha512-RoxnW1zAjBWC3XK+row7Qj5toRMRlKNN/p3FLXb6fTGKxDGWT6JP/mcNX1yf09xRficQ308hbwiedgniepSp1Q==} + engines: {node: '>=20'} + peerDependencies: + tailwindcss: ^4.0.0 tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -11950,13 +11886,6 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - third-party-web@0.29.2: resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} @@ -12054,9 +11983,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -14815,7 +14741,7 @@ snapshots: glob: 13.0.6 hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 - lightningcss: 1.33.0 + lightningcss: 1.30.1 picomatch: 4.0.7 postcss: 8.5.28 resolve-from: 5.0.0 @@ -17184,7 +17110,7 @@ snapshots: dependencies: '@react-email/text': 0.1.6(react@19.2.3) react: 19.2.3 - tailwindcss: 4.3.2 + tailwindcss: 4.3.3 optionalDependencies: '@react-email/body': 0.3.0(react@19.2.3) '@react-email/button': 0.2.1(react@19.2.3) @@ -17817,10 +17743,18 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/typography@0.5.20(tailwindcss@4.3.2)': + '@tailwindcss/postcss@4.3.3': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.28 + tailwindcss: 4.3.3 + + '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.3.2 + tailwindcss: 4.3.3 '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: @@ -18165,6 +18099,10 @@ snapshots: dependencies: '@types/node': 26.4.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/diff@8.0.0': @@ -18210,6 +18148,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/ms@2.1.0': {} + '@types/nlcst@2.0.3': dependencies: '@types/unist': 3.0.3 @@ -18230,7 +18170,7 @@ snapshots: '@types/pg@8.20.0': dependencies: - '@types/node': 24.13.2 + '@types/node': 26.4.1 pg-protocol: 1.15.0 pg-types: 2.2.0 @@ -18761,8 +18701,6 @@ snapshots: ansis@4.3.1: {} - any-promise@1.3.0: {} - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -19067,6 +19005,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 + babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421: + dependencies: + '@babel/types': 7.29.8 + babel-plugin-react-native-web@0.21.2: {} babel-plugin-syntax-hermes-parser@0.36.0: @@ -19506,8 +19448,6 @@ snapshots: callsites@3.1.0: {} - camelcase-css@2.0.1: {} - camelcase@5.3.1: {} camelcase@6.3.0: {} @@ -19709,6 +19649,8 @@ snapshots: colorette@2.0.20: {} + colorjs.io@0.6.0-alpha.1: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -19725,8 +19667,6 @@ snapshots: commander@2.20.3: {} - commander@4.1.1: {} - commander@7.2.0: {} comment-json@4.6.2: @@ -20115,8 +20055,6 @@ snapshots: destroy@1.2.0: {} - detect-libc@1.0.3: {} - detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -20191,16 +20129,12 @@ snapshots: devtools-protocol@0.0.1666840: {} - didyoumean@1.2.2: {} - diff-sequences@29.6.3: {} diff@8.0.4: {} diff@9.0.0: {} - dlv@1.1.3: {} - dnssd-advertise@1.1.6: {} dom-serializer@2.0.0: @@ -21335,10 +21269,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -22488,8 +22418,6 @@ snapshots: jimp-compact@0.16.1: {} - jiti@1.21.7: {} - jiti@2.6.1: {} jiti@2.7.0: {} @@ -22683,7 +22611,7 @@ snapshots: lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.27.0: + lightningcss-darwin-arm64@1.30.1: optional: true lightningcss-darwin-arm64@1.32.0: @@ -22692,7 +22620,7 @@ snapshots: lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.27.0: + lightningcss-darwin-x64@1.30.1: optional: true lightningcss-darwin-x64@1.32.0: @@ -22701,7 +22629,7 @@ snapshots: lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.27.0: + lightningcss-freebsd-x64@1.30.1: optional: true lightningcss-freebsd-x64@1.32.0: @@ -22710,7 +22638,7 @@ snapshots: lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.27.0: + lightningcss-linux-arm-gnueabihf@1.30.1: optional: true lightningcss-linux-arm-gnueabihf@1.32.0: @@ -22719,7 +22647,7 @@ snapshots: lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.27.0: + lightningcss-linux-arm64-gnu@1.30.1: optional: true lightningcss-linux-arm64-gnu@1.32.0: @@ -22728,7 +22656,7 @@ snapshots: lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.27.0: + lightningcss-linux-arm64-musl@1.30.1: optional: true lightningcss-linux-arm64-musl@1.32.0: @@ -22737,7 +22665,7 @@ snapshots: lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.27.0: + lightningcss-linux-x64-gnu@1.30.1: optional: true lightningcss-linux-x64-gnu@1.32.0: @@ -22746,7 +22674,7 @@ snapshots: lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.27.0: + lightningcss-linux-x64-musl@1.30.1: optional: true lightningcss-linux-x64-musl@1.32.0: @@ -22755,7 +22683,7 @@ snapshots: lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.27.0: + lightningcss-win32-arm64-msvc@1.30.1: optional: true lightningcss-win32-arm64-msvc@1.32.0: @@ -22764,7 +22692,7 @@ snapshots: lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.27.0: + lightningcss-win32-x64-msvc@1.30.1: optional: true lightningcss-win32-x64-msvc@1.32.0: @@ -22773,20 +22701,20 @@ snapshots: lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.27.0: + lightningcss@1.30.1: dependencies: - detect-libc: 1.0.3 + detect-libc: 2.1.2 optionalDependencies: - lightningcss-darwin-arm64: 1.27.0 - lightningcss-darwin-x64: 1.27.0 - lightningcss-freebsd-x64: 1.27.0 - lightningcss-linux-arm-gnueabihf: 1.27.0 - lightningcss-linux-arm64-gnu: 1.27.0 - lightningcss-linux-arm64-musl: 1.27.0 - lightningcss-linux-x64-gnu: 1.27.0 - lightningcss-linux-x64-musl: 1.27.0 - lightningcss-win32-arm64-msvc: 1.27.0 - lightningcss-win32-x64-msvc: 1.27.0 + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 lightningcss@1.32.0: dependencies: @@ -23315,12 +23243,6 @@ snapshots: mute-stream@3.0.0: {} - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.15: {} nanoid@3.3.18: {} @@ -23333,19 +23255,11 @@ snapshots: napi-postinstall@0.3.4: {} - nativewind@4.2.6(04ea67d64433bb4fe6d83655c1150d1d): + nativewind@5.0.0-preview.4(react-native-css@3.0.7(@expo/metro-config@57.0.12(expo@57.0.20)(typescript@6.0.3))(lightningcss@1.30.1)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(tailwindcss@4.3.3): dependencies: - comment-json: 4.6.2 - debug: 4.4.3 - react-native-css-interop: 0.2.6(04ea67d64433bb4fe6d83655c1150d1d) - tailwindcss: 3.4.19(tsx@4.23.13)(yaml@2.9.0) - transitivePeerDependencies: - - react - - react-native - - react-native-reanimated - - react-native-safe-area-context - - react-native-svg - - supports-color + react-native-css: 3.0.7(@expo/metro-config@57.0.12(expo@57.0.20)(typescript@6.0.3))(lightningcss@1.30.1)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + tailwindcss: 4.3.3 + tailwindcss-safe-area: 1.3.0(tailwindcss@4.3.3) natural-compare@1.4.0: {} @@ -23541,8 +23455,6 @@ snapshots: object-assign@4.1.1: {} - object-hash@3.0.0: {} - object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -23977,8 +23889,6 @@ snapshots: pidtree@0.6.1: {} - pify@2.3.0: {} - pirates@4.0.7: {} pkg-dir@4.2.0: @@ -24009,42 +23919,11 @@ snapshots: postal-mime@2.7.5: {} - postcss-import@15.1.0(postcss@8.5.16): - dependencies: - postcss: 8.5.16 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.12 - - postcss-js@4.1.0(postcss@8.5.16): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.16 - - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.16)(tsx@4.23.13)(yaml@2.9.0): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 1.21.7 - postcss: 8.5.16 - tsx: 4.23.13 - yaml: 2.9.0 - - postcss-nested@6.2.0(postcss@8.5.16): - dependencies: - postcss: 8.5.16 - postcss-selector-parser: 6.1.4 - postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@6.1.4: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss-value-parser@4.2.0: {} postcss-values-parser@6.0.2(postcss@8.5.28): @@ -24355,20 +24234,17 @@ snapshots: react-is@19.2.8: {} - react-native-css-interop@0.2.6(04ea67d64433bb4fe6d83655c1150d1d): + react-native-css@3.0.7(@expo/metro-config@57.0.12(expo@57.0.20)(typescript@6.0.3))(lightningcss@1.30.1)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): dependencies: - '@babel/helper-module-imports': 7.29.7 - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 + '@expo/metro-config': 57.0.12(expo@57.0.20)(typescript@6.0.3) + '@types/debug': 4.1.13 + babel-plugin-react-compiler: 19.1.0-rc.1-rc-af1b7da-20250421 + colorjs.io: 0.6.0-alpha.1 + comment-json: 4.6.2 debug: 4.4.3 - lightningcss: 1.27.0 + lightningcss: 1.30.1 react: 19.2.8 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - react-native-reanimated: 4.6.0(react-native-worklets@0.12.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) - semver: 7.8.5 - tailwindcss: 3.4.19(tsx@4.23.13)(yaml@2.9.0) - optionalDependencies: - react-native-safe-area-context: 5.9.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.8))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - supports-color @@ -24543,10 +24419,6 @@ snapshots: react@19.2.8: {} - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - read-package-up@11.0.0: dependencies: find-up-simple: 1.0.1 @@ -25485,16 +25357,6 @@ snapshots: styleq@0.1.3: {} - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.17 - ts-interface-checker: 0.1.13 - suf-log@2.5.3: dependencies: s.color: 0.0.15 @@ -25542,35 +25404,9 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0): + tailwindcss-safe-area@1.3.0(tailwindcss@4.3.3): dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.16 - postcss-import: 15.1.0(postcss@8.5.16) - postcss-js: 4.1.0(postcss@8.5.16) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.16)(tsx@4.23.13)(yaml@2.9.0) - postcss-nested: 6.2.0(postcss@8.5.16) - postcss-selector-parser: 6.1.4 - resolve: 1.22.12 - sucrase: 3.35.1 - transitivePeerDependencies: - - tsx - - yaml - - tailwindcss@4.3.2: {} + tailwindcss: 4.3.3 tailwindcss@4.3.3: {} @@ -25657,14 +25493,6 @@ snapshots: text-hex@1.0.0: {} - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - third-party-web@0.29.2: {} throat@5.0.0: {} @@ -25740,8 +25568,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-interface-checker@0.1.13: {} - tsconfck@3.1.6(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dad1176e..01fe7ccc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,6 +41,28 @@ catalog: '@types/react-dom': ^19.2.7 '@types/node': ^24.10.2 +# react-native-css 3 (NativeWind v5) resolves lightningcss through +# @expo/metro-config rather than through its own peer dependency, and +# lightningcss >= 1.32 regressed the napi visitor bridge: with a JS `visitor` +# installed, any unresolved `var()` makes `transform()` throw +# `failed to deserialize; expected an object-like struct named Specifier`. +# Tailwind v4's preflight and `@property` rules both emit such vars, so every +# Metro build (and the jest globalSetup that mirrors it) would fail. 1.30.1 is +# the version NativeWind's v5 migration guide pins. Scoped to @expo/metro-config +# so @tailwindcss/node keeps its own exact 1.32.0 for the website build. +# +# Removable once a lightningcss release deserializes visitor specifiers again, +# or once react-native-css stops routing through @expo/metro-config's copy. The +# check is cheap and local: delete this entry, reinstall, and run +# `pnpm --filter mobile test` — jest's globalSetup drives the same +# PostCSS/Tailwind → lightningcss path Metro does, so it throws the same +# deserialize error if the bug is still there. Unpin it together with the +# matching `lightningcss` devDependency in apps/mobile/package.json (see the +# `//lightningcss` note there) — that one satisfies react-native-css's peer +# range, and the two versions have to stay identical. +overrides: + '@expo/metro-config>lightningcss': 1.30.1 + # Supply-chain guard: quarantine newly published versions for 2 days (2880 min) # before they're installable. minimumReleaseAge: 2880