---
name: alouette-theming
description: >
  Re-theme a subtree with accents (brand, danger, info, success, warning) and
  light/dark modes. Use the accent prop, AccentScope, or ScopedTheme; children
  always consume base tokens (bg-surface, text-accent, text-sharp, text-muted,
  border-muted). Tokens are className-only: alouette exports no JS token-read
  hook. Read the current mode with useCurrentMode. For an accent that toggles at
  runtime without remounting, use StableAccentScope; inside a portal (a modal or
  any other overlay), where the scope escapes the themed subtree, use
  PortalAccentScope. All three, and the pressables, take accent="neutral": the plain
  mode theme, which drops an inherited accent; with variant="soft" it makes a
  Button the secondary beside an accented one. A stored light/dark choice is a
  ColorModePreference ("light" | "dark" | "system") the app passes to
  ScopedTheme as is: "system" is resolved in CSS on web, so a prerendered page
  never flashes the wrong mode. Load when
  applying colors, accents or dark mode, or when shipping a custom palette.
sources:
  - packages/alouette/src/ui/containers/AccentScope.tsx
  - packages/alouette/src/ui/containers/StableAccentScope.tsx
  - packages/alouette/src/ui/containers/PortalAccentScope.tsx
  - packages/alouette/src/ui/containers/PortalAccentScope.web.tsx
  - packages/alouette/src/ui/containers/ScopedTheme.tsx
  - packages/alouette/src/ui/containers/ScopedTheme.web.tsx
  - packages/alouette/src/core/AlouetteConfig.ts
  - packages/alouette/src/core/AlouetteProvider.tsx
  - packages/alouette/src/core/NativeThemeVariablesContext.ts
  - packages/alouette/src/core/ThemeContext.ts
  - packages/alouette/src/core/useColorMode.ts
  - packages/alouette/src/core/useSystemColorMode.web.ts
  - packages/alouette/src/theme-generator/buildTheme.ts
  - packages/alouette/src/theme-generator/generateTheme.ts
  - packages/alouette/src/theme-generator/tokenScaleMap.ts
  - packages/alouette/src/theme-generator/writeTheme.ts
  - CLAUDE.md
---

# alouette — Theming with modes and accents

alouette colors come from theme tokens, not the raw Tailwind palette. A theme is
a light/dark mode optionally combined with an accent. Tokens cascade down the
tree: components use **base tokens** (`bg-surface`, `text-accent`, `text-sharp`,
`text-muted`, `border-muted`) and inherit the resolved value from the nearest
scope. Setting an accent re-themes a whole subtree.

`Accent` = `"brand" | "danger" | "info" | "success" | "warning"`, and
`AccentOrNeutral` adds `"neutral"` — both exported, and `AccentOrNeutral` is what
every public `accent` prop takes.

## Setup

Most alouette components — `Text`, `Box`, `Button`, `Message`, … — take an
`accent` prop that re-themes their subtree. Prefer the prop:

```tsx
import { Box, Text } from "alouette";

<Box accent="danger" className="surface">
  <Text className="text-accent">Something went wrong</Text>
</Box>

<Text accent="brand" className="text-accent">Brand-accented text</Text>;
```

Use `AccentScope` only to re-theme a group of children at once, or children that
don't accept an `accent` prop:

```tsx
import { AccentScope } from "alouette";

<AccentScope accent="brand">
  <Header />
  <Body />
</AccentScope>;
```

## Core Patterns

### Tokens are className-only

alouette exports **no** hook to read a token value in JS. Apply every color
through a className (`bg-surface`, `text-accent`, `border-muted`) — that is the
only path that works on both platforms, because the two resolve a theme
differently:

- **web** — `ScopedTheme` renders a `display: contents` element carrying the
  theme name as a className; the palette CSS resolves the variables. The
  `themeVariables` map is not consulted at all.
- **native** — `ScopedTheme` pushes the theme's variables through NativeWind's
  `VariableContextProvider`, from the `themeVariables` map given to
  `AlouetteProvider`.

The handful of props that cannot take a className (native `Switch` colors,
`placeholderTextColor`, SVG tint, the Expo browser chrome) are handled inside
alouette itself, on native only; web styles them with CSS.

### Force a mode on a subtree

```tsx
import { AccentScope } from "alouette";

<AccentScope mode="dark" accent="brand">
  {children}
</AccentScope>;
```

### Drop an inherited accent

`accent` takes `"neutral"` alongside the five accents — on `AccentScope`,
`StableAccentScope` and `PortalAccentScope` alike. It resolves to the plain mode
theme (`light` / `dark`), so the subtree falls back to the neutral tokens even
under an accented ancestor. This is not the same as omitting `accent`: omitting
it renders no scope at all and the ancestor's accent keeps cascading.

```tsx
<AccentScope accent="danger">
  <Text className="text-accent">Delete</Text> {/* danger */}
  <AccentScope accent="neutral">
    <Text className="text-accent">Details</Text>{" "}
    {/* back to the mode's accent */}
  </AccentScope>
</AccentScope>
```

`InputTextAutocomplete` uses it for its menu: the field is accented, the listbox
that opens next to it is not.

The pressables take it too — `Button`, `IconButton`, `PressableBox`,
`PressableListItem` — where it is more than an escape hatch: `accent="neutral"` is
the **neutral button**, the same material (ground, shadow, hover/focus/press) on
the grayscale palette — an accent like any other: a white card with sharp ink
for the default `tonal` variant, and for `filled` the sharp ink turned into a
ground (black under a white label in light mode, white under a dark label in
dark).
Beside an accented action, the secondary one is neutral **and** `soft` — a
neutral text button — never `variant="outlined"` (alouette-actions/SKILL.md).

```tsx
<Button accent="neutral" variant="soft" text="Cancel" onPress={close} />
<Button text="Save" onPress={save} />
```

### Read the active mode / theme

```tsx
import { useCurrentMode, useCurrentTheme } from "alouette";

const mode = useCurrentMode(); // "light" | "dark"
const theme = useCurrentTheme(); // e.g. "dark_brand"
```

Both resolve a `system` scope to the OS mode. On web they are hydration-safe:
they read `light` during the server render and hydration, then the client's mode
on the next render — so a JS read (a status bar style, a computed color) never
mismatches the prerendered HTML.

### Apply a stored light/dark preference

A stored choice is a `ColorModePreference`: `"light" | "dark" | "system"`.
Applying it is the app's job — pass it **as is** to a `ScopedTheme` around the
tree; `ColorModePicker` (alouette-inputs/SKILL.md) is the ready-made control that
reports the choice. `AlouetteProvider` already applies `system`.

A `system` scope renders the `system` class (and `system_<accent>` under an
accent), which the palette CSS resolves with `prefers-color-scheme`: the
prerendered HTML and the hydration render are identical, and a dark-mode visitor
never sees the light page first. `:root` follows it as long as a `system` scope
is in the document, for `html` and `body`. An explicit `light` / `dark` keeps its
own classes, which the media query never touches. Native has no prerender and
resolves `system` with `useColorScheme`.

Never resolve the preference before applying it: a `ScopedTheme` given
`useResolvedColorMode(preference)` renders `light` on the server, and the page
flashes. `useResolvedColorMode(preference)` and `useSystemColorMode()` are for
what reads the mode in JS, and are hydration-safe like `useCurrentMode`.

```tsx
import {
  type ColorModePreference,
  ColorModePicker,
  ScopedTheme,
} from "alouette";

const [preference, setPreference] = useState<ColorModePreference>("system");

<ScopedTheme theme={preference}>
  <ColorModePicker value={preference} onValueChange={setPreference} />
</ScopedTheme>;
```

### Toggle an accent without remounting the subtree

```tsx
import { StableAccentScope } from "alouette";

const [pendingRemoval, setPendingRemoval] = useState(false);

<StableAccentScope accent={pendingRemoval ? "danger" : undefined}>
  <IconButton
    onHoverIn={() => setPendingRemoval(true)}
    onHoverOut={() => setPendingRemoval(false)}
    onPress={onRemove}
  />
</StableAccentScope>;
```

### Theme a portalled subtree

A portal renders outside the themed DOM subtree, so on web the theme className of
its ancestors no longer applies and the accent has to be rebuilt from the base
mode. `PortalAccentScope` does that — it is what `Modal` uses internally, so
`Modal`'s own `accent` prop already works. Reach for it directly only when you
portal something yourself:

```tsx
import { PortalAccentScope } from "alouette";

<RNModal transparent visible={visible}>
  <PortalAccentScope accent="danger">{overlay}</PortalAccentScope>
</RNModal>;
```

Web nests two scopes — the outer one re-declares the full `light`/`dark` token
set the portal escaped, the inner one layers the accent on top — and both stay
mounted, so toggling `accent` never remounts the subtree. Native has no portal
problem (`ScopedTheme` pushes merged variables through React context, which
crosses wherever the host renders the tree), so there it is a single
`StableAccentScope`.

Both `StableAccentScope` and `PortalAccentScope` take `mode` to force light or
dark, as `AccentScope` does. Without an `accent` they keep the inherited accent
and re-apply it in that mode: `<PortalAccentScope mode="dark">` under
`light_danger` renders `dark_danger`.

### Ship a custom palette for the existing accents

An app re-colors the existing accents (`brand`, `danger`, `info`, `success`,
`warning`, `grayscale`) from a build script: `writeTheme` from
`alouette/theme-generator` (node-only, never imported by app code) writes the
palette CSS, an optional OKLCH overlay and the `themeVariables` map. The app
then imports `alouette/core.css` + its palette instead of `alouette/global.css`,
and passes **its own** map to `AlouetteProvider` — the CSS and the map are two
halves of one theme, and the default map under a custom palette leaves every
native token read on the default colors. The accent set is fixed: this
re-colors accents, it never adds one.

```ts
// scripts/build-theme.ts
import { writeTheme } from "alouette/theme-generator";

writeTheme({
  outDir: "src",
  overrides: { brand: { type: "accent", hue: 300 } },
});
```

Output files, the CSS imports, `generateTheme` in memory and the `PaletteSpec`
parameters: [references/custom-palette.md](references/custom-palette.md).

### Color format: OKLCH on web, hex on native

Every palette is computed in OKLCH and emitted twice — sRGB hex (what native
renders; React Native's color parser takes hex/rgb/hsl/hwb only) and OKLCH with
display-p3 chroma headroom (what web renders: same lightness and hue ramp, more
vivid accents on wide-gamut screens). Hex is the baseline; OKLCH is a separate
opt-in stylesheet, imported after the hex palette:

|        | palette CSS                                                             | `themeVariables` map               |
| ------ | ----------------------------------------------------------------------- | ---------------------------------- |
| web    | `palette-oklch.css` / `alouette/default-palette-oklch.css`, if imported | not read — web theming is CSS only |
| native | `palette.css` (plain hex)                                               | `themeVariables.ts` (hex)          |

The OKLCH CSS is additive — it re-declares the same variables behind
`@supports (color: oklch(0 0 0))`, and the native compiler drops the feature query
and keeps the hex. The palette CSS uses the same trick for the `.<theme>`
blocks (the twelve explicit ones and the six `.system*` ones), which are a web-only mechanism (native themes through
`VariableContextProvider`, not classNames): they sit behind
`@supports (display: contents)` and never reach the native bundle — which is why
native needs the `themeVariables` map at all.

The map has no OKLCH variant: the web build of `AlouetteProvider` ignores
`themeVariables` entirely, so `writeTheme` emits the hex map only — the one
native consumes. The default is `alouette/defaultThemeVariables` (hex, every
platform).

## Common Mistakes

### HIGH Hardcoding raw Tailwind colors instead of tokens

Wrong:

```tsx
<View className="bg-blue-500">
  <Text className="text-gray-600">Hi</Text>
</View>
```

Correct:

```tsx
<Box accent="brand" className="surface">
  <Text className="text-accent">Hi</Text>
</Box>
```

Raw palette classes (`bg-blue-500`, `text-gray-600`) ignore the alouette theme,
so they do not adapt to mode or accent and break dark mode.

Source: CLAUDE.md (Theming and semantic roles); src/ui/containers/AccentScope.tsx

### MEDIUM Setting color manually instead of an accent

Wrong:

```tsx
<Box>
  <Text style={{ color: "#c00" }}>Error</Text>
</Box>
```

Correct:

```tsx
<Box accent="danger">
  <Text className="text-accent">Error</Text>
</Box>
```

Setting `accent` re-themes the subtree so children resolve base tokens against
the accent + current mode; a hardcoded color duplicates theme logic and skips
mode adaptation.

Source: packages/alouette/src/ui/containers/Box.tsx, AccentScope.tsx

### MEDIUM Wrapping an accent-capable component in AccentScope

Wrong:

```tsx
<AccentScope accent="brand">
  <Text className="text-accent">Title</Text>
</AccentScope>
```

Correct:

```tsx
<Text accent="brand" className="text-accent">
  Title
</Text>
```

`Text`, `Box`, `Button`, `Message` and others accept `accent`
directly. Reserve `AccentScope` for grouping several children or wrapping ones
that don't take the prop.

Source: packages/alouette/src/ui/primitives/Text.tsx, ui/containers/AccentScope.tsx

### HIGH Reading a token value in JS instead of applying a className

Wrong:

```tsx
import { useUnstableNativeVariable } from "nativewind";
const color = useUnstableNativeVariable("--color-accent");
<Text style={{ color }}>Hi</Text>;
```

Correct:

```tsx
<Text className="text-accent">Hi</Text>
```

alouette exports no token-read hook (`useThemeToken` was removed in 21.0.0).
Reaching into NativeWind's variable context directly is a native-only path: on
web the theme is a className resolved by the palette CSS and nothing populates
that context, so the read comes back `undefined` and the text falls back to an
unthemed color. Style with the token className on both platforms.

Source: packages/alouette/src/ui/containers/ScopedTheme.web.tsx, core/AlouetteProvider.web.tsx

### MEDIUM Custom palette CSS paired with the default themeVariables

Wrong — importing a generated palette CSS but leaving `AlouetteProvider` on the
default map:

```tsx
// global.css imports core.css + ./palette.css, but:
import { themeVariables } from "alouette/defaultThemeVariables";
<AlouetteProvider themeVariables={themeVariables}>
  {/* app */}
</AlouetteProvider>;
```

Correct:

```tsx
import { themeVariables } from "./themeVariables"; // writeTheme output
<AlouetteProvider themeVariables={themeVariables}>
  {/* app */}
</AlouetteProvider>;
```

A theme has two coupled outputs: the palette CSS (className tokens, the only
thing web uses) and the `themeVariables` map (native token reads — gradients,
`Switch`, `placeholderTextColor`, SVG tint). The prop is required, so this fails
silently rather than loudly, and **only on device**: the default map type-checks
fine, classNames use the custom palette, and the few native reads resolve to the
default colors — so native gradients and controls mismatch the rest of the UI
while web looks correct. Both must come from the same `writeTheme` call — that is
why it writes the pair.

Source: packages/alouette/src/core/NativeThemeVariablesContext.ts, theme-generator/writeTheme.ts

### MEDIUM Expecting var() chains to resolve on native

Wrong:

```css
--color-accent: var(--color-brand);
```

Correct:

```css
--color-accent: #2563eb;
```

On native, NativeWind resolves CSS variables from a lookup table and cannot
follow a `var()` that points at another `var()`. alouette sub-themes use
concrete hex values per mode+accent for this reason.

Source: CLAUDE.md (Native constraint: no CSS variable chains)

### MEDIUM Using AccentScope for an accent that toggles at runtime

Wrong:

```tsx
<AccentScope accent={hovered ? "danger" : undefined}>
  <RowContent />
</AccentScope>
```

Correct:

```tsx
<StableAccentScope accent={hovered ? "danger" : undefined}>
  <RowContent />
</StableAccentScope>
```

`AccentScope` only renders its `ScopedTheme` wrapper when `accent` is set —
it returns `children` unwrapped otherwise. Toggling `accent` therefore mounts
or unmounts that wrapper in either direction, remounting the subtree and
dropping focus from any input inside. `StableAccentScope` always keeps
`ScopedTheme` mounted, falling back to the inherited theme when `accent` is
unset, so toggling only changes the theme prop. Prefer `AccentScope`
when the accent is fixed; reach for `StableAccentScope` only when it toggles.

Source: packages/alouette/src/ui/containers/StableAccentScope.tsx

## References

- [Token catalog](references/tokens.md) — color, spacing, radius and shadow token names.
- [Custom palette](references/custom-palette.md) — `writeTheme`, the generated files and `PaletteSpec`.

See also: alouette-typography/SKILL.md — color tokens are applied through Text.
