# Styling and theming the Expo app | Expo Supabase SaaS Kit

> Restyle the native app to your brand with Uniwind's CSS-first theme tokens, wire up light/dark/system, and add UI components to @kit/mobile-ui without losing them on the next sync.

*Canonical: https://makerkit.dev/docs/react-native-supabase/styling-theming*

---

The Expo app is styled with Tailwind utility classes, the same way `apps/web` is. This page covers the one file you edit to rebrand it, how the light/dark/system preference is stored and applied, what `@kit/mobile-ui` gives you, and where a new component belongs so a component-library resync does not delete it.

## The model: Tailwind v4 without a config file

Styling goes through **Uniwind**, which compiles Tailwind v4 for React Native. You write `className` on React Native components and Uniwind resolves the utilities into RN styles at build time through its Metro transformer.

Two things differ from what you know from the web app:

| | `apps/web` | `apps/native` |
| --- | --- | --- |
| Engine | Tailwind v4 via PostCSS | Tailwind v4 via Uniwind's Metro transformer |
| Config | CSS-first (`@theme`), no `tailwind.config.js` | CSS-first (`@theme` / `@variant`), no `tailwind.config.js` |
| Where classes land | real CSS in the browser | RN `style` objects — there is no CSS box model, no cascade, no `:hover` on device |
| Theme switch | `.dark` class on the document | `Uniwind.setTheme()`, a runtime API |

Because there is no cascade, only utilities Uniwind can map to an RN style property do anything. A class that has no RN equivalent is resolved and then dropped silently, with no build warning. This is the single most common surprise when porting a web component by hand.

Installed versions, from `pnpm-workspace.yaml` (the `expo` named catalog) and the resolved lockfile:

| Package | Version |
| --- | --- |
| `uniwind` | `^1.10.0` (installed `1.10.0`) |
| `tailwindcss` | `4.3.3` |
| `lucide-react-native` | `1.27.0` |
| `@react-native-reusables/cli` | `^0.7.1` |

{% alert type="warning" title="Never add a tailwind.config.js" %}
Neither `apps/native` nor `packages/mobile-ui` has one, and adding one does not do what you want — Uniwind reads its theme from CSS. Every theme change goes in `apps/native/global.css`.
{% /alert %}

## `apps/native/global.css` — the whole theme

This is the Tailwind + Uniwind entrypoint. It is registered with Metro in `apps/native/metro.config.js`:

```js
const config = withUniwindConfig(getDefaultConfig(__dirname), {
  cssEntryFile: './global.css',
  dtsFile: './uniwind-types.d.ts',
});
```

and imported exactly once, as the **first line** of `apps/native/app/_layout.tsx`:

```tsx
import '../global.css';
```

Do not import it anywhere else, and do not remove either half of `metro.config.js` — the `withUniwindConfig` wrapper must stay outermost, and the resolver block under it pins a single copy of React so `@kit/*` packages cannot drag a second one into the bundle.

### Scanning mobile-ui's classes

```css
@source '../../packages/mobile-ui/src/**/*.{ts,tsx}';
```

Tailwind's automatic source detection is rooted at the directory containing the CSS entry file — Uniwind passes `base: path.dirname(cssPath)` when it compiles, which is `apps/native/`. Class names that appear *only* inside `packages/mobile-ui/src` are therefore invisible to the compiler, and every utility used by a Button, Card or Input would be stripped from the generated stylesheet. The `@source` directive adds that tree back.

If you add another workspace package that ships `className` strings, it needs its own `@source` line here.

### The tokens

Uniwind's bundler recognises `@variant <theme>` rules, and it requires every registered theme to declare **the same variable set**. So the palette lives in two mirrored blocks, `@variant light` and `@variant dark`, and non-themed tokens live in the `@theme` block above them.

| Token | Light | Dark |
| --- | --- | --- |
| `--color-background` | `hsl(0 0% 100%)` | `hsl(240 10% 3.9%)` |
| `--color-foreground` | `hsl(240 10% 3.9%)` | `hsl(0 0% 98%)` |
| `--color-card` | `hsl(0 0% 100%)` | `hsl(240 10% 3.9%)` |
| `--color-card-foreground` | `hsl(240 10% 3.9%)` | `hsl(0 0% 98%)` |
| `--color-popover` | `hsl(0 0% 100%)` | `hsl(240 10% 3.9%)` |
| `--color-popover-foreground` | `hsl(240 10% 3.9%)` | `hsl(0 0% 98%)` |
| `--color-primary` | `hsl(240 5.9% 10%)` | `hsl(0 0% 98%)` |
| `--color-primary-foreground` | `hsl(0 0% 98%)` | `hsl(240 5.9% 10%)` |
| `--color-secondary` | `hsl(240 4.8% 95.9%)` | `hsl(240 3.7% 15.9%)` |
| `--color-secondary-foreground` | `hsl(240 5.9% 10%)` | `hsl(0 0% 98%)` |
| `--color-muted` | `hsl(240 4.8% 95.9%)` | `hsl(240 3.7% 15.9%)` |
| `--color-muted-foreground` | `hsl(240 3.8% 46.1%)` | `hsl(240 5% 64.9%)` |
| `--color-accent` | `hsl(240 4.8% 95.9%)` | `hsl(240 3.7% 15.9%)` |
| `--color-accent-foreground` | `hsl(240 5.9% 10%)` | `hsl(0 0% 98%)` |
| `--color-destructive` | `hsl(359 74.7% 50.4%)` | `hsl(359 100% 69.6%)` |
| `--color-destructive-foreground` | `hsl(0 0% 98%)` | `hsl(0 0% 98%)` |
| `--color-success` | `hsl(142 76% 36%)` | `hsl(142 77% 73%)` |
| `--color-border` | `hsl(240 5.9% 90%)` | `hsl(240 3.7% 15.9%)` |
| `--color-input` | `hsl(240 5.9% 90%)` | `hsl(240 3.7% 15.9%)` |
| `--color-ring` | `hsl(240 5.9% 10%)` | `hsl(240 4.9% 83.9%)` |

Plus one non-themed token in the `@theme` block: `--radius: 0.5rem`.

Each `--color-<name>` token generates the usual utility families — `bg-<name>`, `text-<name>`, `border-<name>` — so `bg-background`, `text-muted-foreground` and `border-border` behave exactly as on web.

## Rebranding

Editing the palette is the whole job:

1. Open `apps/native/global.css`.
2. Change the values inside `@variant light`. Keep every declared token — Uniwind rejects a theme that declares a different variable set from its sibling.
3. Change the matching values inside `@variant dark`.
4. Adjust `--radius` in the `@theme` block if your brand is rounder or squarer.
5. Restart Metro. The stylesheet is generated at bundle time; a running server picks up CSS edits, but after a `metro.config.js` or dependency change use `pnpm start:native:clear`.

You do not need to touch any component to rebrand, because every component in the kit references tokens rather than literal colours.

{% alert type="warning" title="These tokens are a second copy of the web palette" %}
There is **no shared theme package today**. `apps/native/global.css` and `apps/web/styles/theme.css` are two hand-maintained expressions of the same design system, and they are not even written the same way:

- Web declares bare names (`--background`) in `:root` / `.dark` and aliases them to `--color-*` in `apps/web/styles/shadcn-ui.css`. Native declares `--color-*` directly.
- Web uses `oklch()`. Native uses `hsl()`.
- The sets already differ: native has `--color-success` and `--color-destructive-foreground`, which web's `theme.css` does not define; web has `--chart-1..5` and the `--sidebar-*` family, which native does not. `--radius` is `0.625rem` on web and `0.5rem` on native.

A rebrand means editing **both files**, and drift between them is a known, real risk — the values above are close to web's but are not mechanically derived from them. If drift becomes a maintenance problem in your fork, factor the palette into a shared package and generate both files from it.
{% /alert %}

Brand surfaces that are *not* CSS tokens — the app icon, the Android adaptive-icon background (`#ffffff` in `apps/native/app.json`), the splash screen — are build-time native config. See `docs/native/building-shipping.mdoc` and `docs/native/config-requirements.mdoc`.

## Light, dark and system

The user-facing preference is tri-state (`light | dark | system`), and it is owned by React state, not by Uniwind. `apps/native/lib/theme/theme-preference-context.tsx` explains why: Uniwind stores only the *resolved* binary theme, so the `'system'` intent is lost the moment the OS scheme flips.

{% img src="/images/docs/native-home.webp" width="402" height="874" alt="The native home screen in light mode: header with the workspace switcher pill and settings gear, and the Home / Settings / Members tab bar" /%}

{% img src="/images/docs/native-home-dark.webp" width="402" height="874" alt="The same native home screen in dark mode, with the header, tab bar and status bar all following the theme" /%}

The same screen, one token set apart. Note that the header, the tab bar and the status bar flip too: those are painted by React Navigation and by `expo-status-bar`, not by class names, which is what [React Navigation chrome](#react-navigation-chrome) and `useThemeColors` exist to cover. A rebrand that only edits `global.css` still moves all of it.

| Piece | File | Responsibility |
| --- | --- | --- |
| `ThemePreferenceProvider` / `useThemePreference` | `apps/native/lib/theme/theme-preference-context.tsx` | Holds `preference`, `setPreference`, `isReady`. Calls `Uniwind.setTheme()` on every change. |
| `loadThemePreference` / `saveThemePreference` | `apps/native/lib/theme/theme-storage.ts` | Persistence in `expo-secure-store` under the key `app.themePreference`. Falls back to `'system'` on a missing, unrecognised or failed read. |
| `useNavigationTheme` | `apps/native/lib/theme/use-navigation-theme.ts` | Builds a React Navigation `Theme` from the tokens. |
| `useThemeColors` | `apps/native/lib/theme/use-theme-colors.ts` | Resolves tokens to JS colour strings for native APIs that take colour props. |
| Switcher UI | `apps/native/app/(home)/theme.tsx` | The three-row form-sheet the user actually taps. |

`Uniwind.setTheme('light' | 'dark')` also calls RN's `Appearance.setColorScheme()`, so `useColorScheme()` agrees with the chosen theme app-wide; `setTheme('system')` resets `Appearance` to unspecified and hands control back to the OS.

Persistence uses SecureStore rather than a plain key-value store because the app already depends on it for the session (see `docs/native/authentication.mdoc`), and SecureStore items are app-sandboxed on both the iOS Keychain and the Android Keystore — which is also why a flat, non-namespaced key is safe here.

### The `isReady` gate

`preference` is `null` until the SecureStore read resolves. During that window Uniwind has not been told anything, so it renders with the OS scheme — and a user who chose dark on a light-mode device would see a white flash on every cold start. `apps/native/app/_layout.tsx` blocks the first frame instead:

```tsx
const { preference, isReady } = useThemePreference();

if (!isReady) {
  return (
    <>
      <StatusBar style="light" />
      <View style={{ flex: 1, backgroundColor: '#000' }} />
    </>
  );
}
```

The black frame is deliberate: it is unbranded, so it reads as part of the launch sequence in either theme, and it is a plain `style` (not a `className`) precisely because the themed token is what is not yet trustworthy. If you add work that must happen before the first painted screen, gate it in the same place rather than adding a second splash.

### React Navigation chrome

Headers, card backgrounds and the screen background underneath your content are painted by React Navigation, which reads a `Theme` object rather than class names. `useNavigationTheme` bridges the two: it resolves the tokens through `useThemeColors`, starts from `DefaultTheme`/`DarkTheme` depending on the colour scheme, and overrides `background`, `card`, `text`, `border`, `primary` and `notification` (mapped from `--color-destructive`). It is applied once, in `app/_layout.tsx`:

```tsx
<ExpoRouterThemeProvider value={navigationTheme}>
  <Stack screenOptions={{ headerShown: false }} />
</ExpoRouterThemeProvider>
```

Each override falls back to the base navigation theme's own value if a token fails to resolve, so a typo in a token name degrades to the stock colour instead of rendering `undefined`.

### When you need `useThemeColors`

Reach for it **only** when an API wants a real colour value as a prop and cannot be given a class. The native tab bar is the live example — it builds its appearance from explicit props and ignores the navigation theme entirely:

```tsx
const { muted, mutedForeground, foreground } = useThemeColors();

<NativeTabs
  backgroundColor={muted}
  iconColor={{ default: mutedForeground, selected: foreground }}
  labelStyle={{
    default: { color: mutedForeground },
    selected: { color: foreground },
  }}
  disableIndicator
/>
```

The hook is reactive (it wraps Uniwind's `useCSSVariable`), so colours follow a theme switch without a remount. It exposes eight tokens: `background`, `card`, `foreground`, `muted`, `mutedForeground`, `border`, `primary`, `destructive`, each typed `string | undefined`. If you need a ninth, add it to the `TOKENS` array **and** the destructure in the same order — the file notes that the order is index-mapped.

For everything else, use a `className`. Anything you can express as a utility should be one.

## `@kit/mobile-ui`

`packages/mobile-ui` is the native mirror of `@kit/ui`. Components are **React Native Reusables** (RNR) — shadcn-style primitives copied into `src/components/ui/` by the RNR CLI, built on `@rn-primitives/*` — so the API is close enough to `@kit/ui` that porting a web feature is mostly mechanical.

The authoritative component list is the `exports` map in `packages/mobile-ui/package.json`:

| Import | Source |
| --- | --- |
| `@kit/mobile-ui/utils` | `src/lib/utils.ts` |
| `@kit/mobile-ui/form` | `src/makerkit/form.tsx` |
| `@kit/mobile-ui/sonner` | `src/makerkit/sonner.tsx` |
| `@kit/mobile-ui/trans` | `src/makerkit/trans.tsx` |
| `@kit/mobile-ui/accordion` | `src/components/ui/accordion.tsx` |
| `@kit/mobile-ui/alert` | `src/components/ui/alert.tsx` |
| `@kit/mobile-ui/alert-dialog` | `src/components/ui/alert-dialog.tsx` |
| `@kit/mobile-ui/aspect-ratio` | `src/components/ui/aspect-ratio.tsx` |
| `@kit/mobile-ui/avatar` | `src/components/ui/avatar.tsx` |
| `@kit/mobile-ui/badge` | `src/components/ui/badge.tsx` |
| `@kit/mobile-ui/button` | `src/components/ui/button.tsx` |
| `@kit/mobile-ui/card` | `src/components/ui/card.tsx` |
| `@kit/mobile-ui/checkbox` | `src/components/ui/checkbox.tsx` |
| `@kit/mobile-ui/collapsible` | `src/components/ui/collapsible.tsx` |
| `@kit/mobile-ui/context-menu` | `src/components/ui/context-menu.tsx` |
| `@kit/mobile-ui/dialog` | `src/components/ui/dialog.tsx` |
| `@kit/mobile-ui/dropdown-menu` | `src/components/ui/dropdown-menu.tsx` |
| `@kit/mobile-ui/hover-card` | `src/components/ui/hover-card.tsx` |
| `@kit/mobile-ui/icon` | `src/components/ui/icon.tsx` |
| `@kit/mobile-ui/input` | `src/components/ui/input.tsx` |
| `@kit/mobile-ui/label` | `src/components/ui/label.tsx` |
| `@kit/mobile-ui/menubar` | `src/components/ui/menubar.tsx` |
| `@kit/mobile-ui/native-only-animated-view` | `src/components/ui/native-only-animated-view.tsx` |
| `@kit/mobile-ui/popover` | `src/components/ui/popover.tsx` |
| `@kit/mobile-ui/portal` | `src/components/ui/portal.tsx` |
| `@kit/mobile-ui/progress` | `src/components/ui/progress.tsx` |
| `@kit/mobile-ui/radio-group` | `src/components/ui/radio-group.tsx` |
| `@kit/mobile-ui/select` | `src/components/ui/select.tsx` |
| `@kit/mobile-ui/separator` | `src/components/ui/separator.tsx` |
| `@kit/mobile-ui/skeleton` | `src/components/ui/skeleton.tsx` |
| `@kit/mobile-ui/switch` | `src/components/ui/switch.tsx` |
| `@kit/mobile-ui/tabs` | `src/components/ui/tabs.tsx` |
| `@kit/mobile-ui/text` | `src/components/ui/text.tsx` |
| `@kit/mobile-ui/textarea` | `src/components/ui/textarea.tsx` |
| `@kit/mobile-ui/toggle` | `src/components/ui/toggle.tsx` |
| `@kit/mobile-ui/toggle-group` | `src/components/ui/toggle-group.tsx` |
| `@kit/mobile-ui/tooltip` | `src/components/ui/tooltip.tsx` |

There is no barrel file and no `.` export. Import flat, one component per subpath:

```tsx
import { Icon } from '@kit/mobile-ui/icon';
import { Separator } from '@kit/mobile-ui/separator';
import { Text } from '@kit/mobile-ui/text';
import { cn } from '@kit/mobile-ui/utils';
```

Never write a deep path into `src/components/ui/` from outside the package — a path that is not in `exports` is not part of the contract and will break on the next resync.

Three standing rules when you write native UI:

1. Merge classes with `cn()` from `@kit/mobile-ui/utils` (`twMerge(clsx(...))`), so a caller's `className` can override a component's defaults.
2. Use semantic classes (`bg-background`, `text-muted-foreground`, `border-border`) and **never** a hardcoded RN colour. A literal `#111827` is invisible to the theme switch and will look wrong in one of the two modes.
3. Wrap text in `Text` from `@kit/mobile-ui/text` rather than RN's `Text`, so text-colour class context propagates to nested `Icon`s.

`@kit/mobile-ui/form` is covered in `docs/native/forms.mdoc`; `@kit/mobile-ui/trans` in `docs/native/i18n.mdoc`.

## The rule that matters: never edit `src/components/ui/`

{% alert type="warning" title="RNR overwrites its own directory" %}
`src/components/ui/` is generated. The resync command runs `add --overwrite`, which replaces those files in place with no diff and no prompt. Any project-specific behaviour you add there is lost the next time anyone syncs the component library.
{% /alert %}

Project-specific work goes in **`packages/mobile-ui/src/makerkit/`**, a sibling directory the CLI never touches. It currently holds:

| File | Export | What it is |
| --- | --- | --- |
| `form.tsx` | `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage`, `useFormField` | react-hook-form bindings, mirroring `@kit/ui/form` |
| `sonner.tsx` | `Toaster`, `toast` | House toast surface over `sonner-native`, defaulted to `position="top-center"` |
| `trans.tsx` | `Trans` | RN-flavoured mirror of `@kit/ui`'s `Trans`, same public API |

This is the same split `packages/ui` uses on web: `packages/ui/src/shadcn/` is generated and disposable, `packages/ui/src/makerkit/` is yours. Learning one convention gets you both.

If a generated component genuinely needs different behaviour, prefer wrapping it in `src/makerkit/` over editing it. If you must edit the original, treat that edit as a patch you will have to re-apply, and record it — a resync will silently drop it.

## Adding an RNR component

RNR ships more components than the kit vendors. To pull one in:

```bash
cd packages/mobile-ui
pnpm exec cli add <component> --styling-library uniwind
```

The CLI is configured by `packages/mobile-ui/components.json` (styling library `uniwind`, icon library `lucide`, and — importantly — `tailwind.css` pointing at `../../apps/native/global.css`, so it reads your tokens rather than writing its own). To resync everything:

```bash
pnpx @react-native-reusables/cli@latest add --all --yes --overwrite --styling-library uniwind
```

{% alert type="warning" title="`pnpm rnr:add` does not currently run" %}
`packages/mobile-ui/package.json` defines `"rnr:add": "rnr add"`, but `@react-native-reusables/cli@0.7.1` declares `"bin": "bin.cjs"` without a name, so pnpm links the executable as `cli`, not `rnr`. There is no `rnr` on the path and the script fails with a command-not-found. Use `pnpm exec cli add …` (or the `pnpx` form above) until the script is corrected.
{% /alert %}

Every `add` writes files that import each other through `@/` aliases. `packages/mobile-ui/tsconfig.json` declares no `paths` for `@/`, so those imports do not resolve — **rewrite them to relative paths before doing anything else**:

```bash
cd packages/mobile-ui/src/components/ui
for f in *.tsx; do
  sed -i.bak \
    -e "s|from '@/components/ui/\([^']*\)'|from './\1'|g" \
    -e "s|from '@/lib/utils'|from '../../lib/utils'|g" \
    -e "s|from '@/lib/\([^']*\)'|from '../../lib/\1'|g" \
    -e "s|from '@/hooks/\([^']*\)'|from '../../hooks/\1'|g" \
    "$f"
  rm "$f.bak"
done
```

Then add the new component to the `exports` field of `packages/mobile-ui/package.json` — until you do, `@kit/mobile-ui/<name>` is not importable — and run `pnpm typecheck`.

One packaging note: RNR installs its `@rn-primitives/*` dependencies with `pnpm add` under the hood, and this workspace's `catalogMode: prefer` rewrites those into `catalog:` entries in `pnpm-workspace.yaml` automatically. Check that diff rather than reverting it.

## Adding your own component

Two homes, one question:

| Home | Use when | Import as |
| --- | --- | --- |
| `packages/mobile-ui/src/makerkit/` | The component is a pure, cross-platform UI primitive with no knowledge of your domain, your API or your app's contexts | `@kit/mobile-ui/<name>` (add it to `exports`) |
| `apps/native/features/makerkit/components/` | The component knows something about this app — it reads translations, calls `~/features/core`, or composes domain state | `~/features/makerkit/components/<name>` |

The dividing line is dependencies, not reusability. Anything that imports `~/features/*`, `~/lib/*` or the app's i18n cannot live in the package. Layout glue that composes domain features (the home header, the tab stack) goes higher still, in `apps/native/components/`. The full layer model and the one-way import rule are in `docs/native/project-structure.mdoc`.

Before you build a shared widget, check what already exists in `apps/native/features/makerkit/components/`:

| Component | What it does |
| --- | --- |
| `AppLogo` | Brand mark for the home header. Bundles the web favicon as a raster PNG via a relative `require`; `className` overrides the default `size-6`. |
| `ErrorState` / `InlineErrorState` | Full-screen and inline error surfaces: generic user-facing message, a `__DEV__`-only diagnostic line, a retry action, optional sign-out escape. Never leaks error detail in production. |
| `KeyboardAvoidingScreen` | Full-screen container for headerless form screens. Animated `paddingBottom` on the UI thread plus a `ScrollView` overflow escape. |
| `LoadingState` | Centred `ActivityIndicator` on a `bg-background` full-screen `View`. |
| `OtpInput` | Six-cell code entry: one hidden `TextInput` overlaid on the visual cells, so paste, autofill, backspace and screen-reader focus all work. `length` defaults to 6. |
| `PictureControls` | Shared change/remove picture controls for the personal avatar and the team logo. Uploads to the `account_image` bucket and persists via a caller-supplied `persist` mutation. |
| `SearchInput` | Controlled search field with a leading search icon and a clear button that appears once there is a value. |
| `SettingsLinkRow` | Tappable settings row: leading Lucide icon, title, optional description, trailing chevron. |
| `SettingsSection` | Titled section wrapper with an optional description, for grouping settings rows. |
| `WebOnlyNotice` | Standard card telling the user a feature lives on the web app. Takes an i18n `featureKey`. See `docs/native/covered-features.mdoc`. |
| `WorkspaceAvatar` | Personal/team avatar with web-parity fallbacks (generic user icon or the label's initial). Note: RN's `<Image>` cannot decode SVG, so an SVG source falls through to the fallback. |

## Icons

Icons come from `lucide-react-native`, rendered through the `Icon` wrapper from `@kit/mobile-ui/icon`. You pass the Lucide component as `as` and style it with classes — this is how `app/(home)/theme.tsx` does it:

```tsx
import { Check, Sun, type LucideIcon } from 'lucide-react-native';

import { Icon } from '@kit/mobile-ui/icon';

<Icon as={Sun} className="text-foreground size-5" />
<Icon as={Check} className="text-primary size-5" />
```

`Icon` defaults to `text-foreground size-5` and inherits the surrounding text colour context, so an icon inside a muted row picks up the muted colour without being told.

{% alert type="warning" title="Only `size-*` and text colour reach a Lucide icon" %}
`Icon` is built with `withUniwind` forwarding exactly two style properties — `width` and `color`. Every other class you put on it (`mt-2`, `ml-1`, `shrink-0`, transforms) is resolved and then dropped, with no warning. Put layout classes on a wrapping `View`, or pass `style` directly. The same caveat applies to every `iconClassName` prop in `@kit/mobile-ui`, since they all funnel into this `className`.
{% /alert %}

For icons that are decoration rather than information, set `accessibilityElementsHidden` / `importantForAccessibility="no-hide-descendants"` on the wrapper, as `AppLogo` does.

## Two type-level gotchas

**`placeholderClassName`.** Both `apps/native/uniwind-env.d.ts` and `packages/mobile-ui/src/uniwind-env.d.ts` augment RN's `TextInputProps` with an optional `placeholderClassName`, a leftover from an RNR-vs-Uniwind prop mismatch. Nothing in the repo passes it today — the vendored `input.tsx` and `textarea.tsx` colour their placeholders with Uniwind's own `placeholderTextColorClassName`, which resolves through `accentColor` and therefore needs an `accent-*` utility (`accent-muted-foreground`), not a `text-*` one. A `text-*` class there yields `undefined`.

**`export {}` is load-bearing.** Both `uniwind-env.d.ts` files declare `export {}`. Without it the file is a global script rather than a module, and the `declare module 'react-native'` block inside would *replace* the `react-native` module declaration instead of augmenting it — breaking every `import { Platform } from 'react-native'` in the app. If you touch those files, keep the `export {}`.

Neither file needs manual maintenance otherwise. `uniwind-types.d.ts` next to them is generated by Metro on first run, is gitignored, and is referenced from `apps/native/tsconfig.json`.
