alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
470 lines (360 loc) • 17.3 kB
Markdown
---
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
` (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
` (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.