# ThemeProvider

**When to use:** Applies a theme to its subtree, injecting its CSS custom properties as inline styles so every component inside can use them. [AhroweProvider](AhroweProvider.md) includes one and takes the same `themes` and `currentThemeId`: use that at the app root, and ThemeProvider on its own for a section with a different theme.

**Keywords:** theming, dark mode, light mode, color scheme, colour scheme, prefers-color-scheme, auto, gradient

**Import:** `import { ThemeProvider, useTheme } from '@ahrowe/ui'`  
**Types:** `import type { Theme, ThemeVariables, ThemeProviderProps } from '@ahrowe/ui'`  
**Built-in themes:** `import { defaultTheme, lightTheme } from '@ahrowe/ui'`

```tsx
import { ThemeProvider } from '@ahrowe/ui';
import '@ahrowe/ui/style.css'; // add once in your entry point

// Minimal — uses the built-in default theme
<ThemeProvider>
  <App />
</ThemeProvider>

// With a custom theme
import type { Theme } from '@ahrowe/ui';

const brandTheme: Theme = {
  id: 'brand',
  variables: {
    '--primary-color': '#6200ea',
    '--primary-lighter': '#b388ff',
    '--primary-accent': '#7c4dff',
    '--background': '#ffffff',
    '--text-color': '#212121',
  },
};

<ThemeProvider themes={[brandTheme]} currentThemeId="brand">
  <App />
</ThemeProvider>

// Extending the built-in theme (only override specific variables)
const darkTheme: Theme = {
  id: 'dark',
  baseThemeId: 'default', // inherit everything not listed here
  variables: {
    '--background': '#121212',
    '--background-accent': '#1e1e1e',
    '--text-color': '#ffffff',
  },
};

<ThemeProvider themes={[darkTheme]} currentThemeId="dark">
  <App />
</ThemeProvider>
```

## The two built-in themes

The package ships a pair. `defaultTheme` is dark and is what `ThemeProvider` applies when you pass
no `themes` at all. `lightTheme` is its light sibling: same brand colour, inverted surfaces. Pass
both to let the user choose.

```tsx
import { AhroweProvider, OptionPicker, defaultTheme, lightTheme, useTheme } from '@ahrowe/ui';

// A new array on every render reads as a theme change, so keep it at module scope.
const THEMES = [defaultTheme, lightTheme];

const CHOICES = [
  { key: 'system', label: 'System' },
  { key: 'light', label: 'Light' },
  { key: 'default', label: 'Dark' },
];

function Root() {
  const { currentThemeId, selectedThemeId, setCurrentThemeId } = useTheme('system');

  return (
    <AhroweProvider themes={THEMES} currentThemeId={currentThemeId}>
      <OptionPicker options={CHOICES} value={selectedThemeId} onChange={setCurrentThemeId} />
      <App />
    </AhroweProvider>
  );
}
```

## Choosing a theme (`useTheme`)

`useTheme(defaultThemeId?, systemThemes?)` holds the user's choice and remembers it in
`localStorage` under `theme`, so it survives a reload. It returns:

| Field | Description |
|-------|-------------|
| `currentThemeId` | The theme to apply: pass it to `currentThemeId` |
| `selectedThemeId` | What the user chose, `'system'` included: mark the active choice in your switcher with it |
| `setCurrentThemeId` | Chooses a theme by its `id`, or `'system'`. `setCurrentThemeId(id, { revealFrom: element })` grows the new theme out of that element, see below |

**`'system'`** is a choice like any theme's `id`. While it is chosen, the theme follows the operating
system's light or dark setting, and changes the moment that setting does. Choosing a theme of its
own takes the user off it, and choosing `'system'` again puts them back. Pass it as the default,
`useTheme('system')`, to follow the system until the user picks something else; leave it out of
your choices if your app should not offer it.

It picks between `lightTheme` and `defaultTheme`. For themes of your own, name the pair:

```tsx
useTheme('system', { light: 'brandLight', dark: 'brandDark' });
```

**Revealing the new theme.** Pass the control that chose it, and the new theme spreads across the
page as a rippling blob growing out of it:

```tsx
<button onClick={(event) => setCurrentThemeId('light', { revealFrom: event.currentTarget })}>Light</button>
```

- It takes a browser with view transitions: Chrome and Edge 111, Safari 18, Firefox 144. Elsewhere,
  and when the user asks for reduced motion, the theme just changes.
- Pass `currentThemeId` from the hook straight to `AhroweProvider` or `ThemeProvider`. The new
  theme has to render inside the transition; one copied into state of your own a render later shows
  the old theme growing, then snaps.
- One view transition runs at a time, so a reveal started during another, such as a router's page
  transition, cuts that one short. The theme changes either way.
- A theme that also changes sizes or fonts shows both layouts along the blob's edge while it grows.
- Choosing the theme already chosen reveals nothing.

Every component calling `useTheme` shares the one choice, and a choice made in another tab reaches
this one too. Where storage is blocked, the choice still works until the page is closed.

On the server there is neither storage nor a system setting, so a server-rendered page starts on
`defaultThemeId` (`systemThemes.light` for `'system'`) and switches to the user's choice right
after hydration.

`lightTheme` declares only the twenty-odd variables that have to flip and inherits the rest through
`baseThemeId: 'default'`. That is the shape to copy for your own theme: override what your brand
changes, inherit everything else, so a new variable added to the library reaches your theme without
you touching it.

**Theme type:**

```ts
interface Theme {
  id: string;
  baseThemeId?: string;    // inherit from another theme before applying variables
  variables: ThemeVariables;
}
```

**Key CSS variables** (`ThemeVariables` is the full list, in `node_modules/@ahrowe/ui/dist/types/common/themeProvider/theme.types.d.ts`):

| Variable | Purpose |
|----------|---------|
| `--primary-color` / `--primary-lighter` / `--primary-accent` | Brand colours |
| `--primary-gradient` | Optional gradient layered over `--primary-color` on large filled surfaces (see [Gradient primary](#gradient-primary)) |
| `--background` | Page/surface background |
| `--background-accent` / `--background-accent-light` | Raised surfaces, cards |
| `--text-color` / `--text-dark` | Primary and secondary text |
| `--border-color` | Borders and dividers |
| `--focus-border-color` | Focus ring/border colour used across interactive components (`Input`, `Dropdown`, `Checkbox`, `Switch`, `RadioGroup`, `OptionPicker`, `IconPicker`, `ColorPicker`, `Timeline`, `RoomDrawer`, …) — falls back to `--primary-color` when unset |
| `--error-color` / `--success-color` / `--warn-color` / `--info-color` | Semantic colours |
| `--default-border-radius` | Border radii |
| `--default-font-size` / `--text-small-font-size` / `--header-font-size` | Base text, small text (captions, value bubbles) and headings |
| `--spacing-xs` / `--spacing-s` / `--spacing-m` / `--spacing-l` | Gaps and padding: `--spacing-s`, `-m` and `-l` are 4, 8 and 16px in the built-in themes, `--spacing-xs` falls back to 4px. Use them for your own layout around components too, so it follows the theme |
| `--avatar-radius` | `Avatar` corner radius — falls back to `--default-border-radius` when unset (set to `50%` theme-wide for fully circular avatars) |
| `--option-picker-padding` | Padding of each `OptionPicker` option — falls back to `--spacing-m` when unset |
| `--input-label-resting-color` | Resting (not-floated) material label colour for `Input` & `Dropdown` — falls back to `--text-light` when unset |
| `--input-label-floating-color` | Floating (active) material label colour for `Input` & `Dropdown` — falls back to `--text-color` when unset |
| `--card-shadow` | Elevation shadows |

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | Your app content (required) |
| `themes` | `Theme[]` | Theme definitions — defaults to built-in default theme |
| `currentThemeId` | `string` | Active theme id — must match one of the `themes[].id` values |

**Note:** Without a `ThemeProvider` or `AhroweProvider` above them, all CSS variables are undefined and components will be unstyled.

**Base typography:** The wrapper also sets `font-family`, `font-size`, `font-weight`, and `color` from the theme's `--default-font`, `--default-font-size`, `--default-font-weight`, and `--text-color`, so everything inside — including what `AhroweProvider` portals — inherits the theme's base text style without each component having to set it.

## Gradient primary

Set `--primary-gradient` next to `--primary-color` to give the brand a gradient:

```ts
const brandTheme: Theme = {
  id: 'brand',
  variables: {
    '--primary-color': '#8a4bd6',
    '--primary-gradient': 'linear-gradient(135deg, #6d4bd6 0%, #a24bd6 55%, #4b7ed6 100%)',
    '--text-on-primary': '#ffffff',
  },
};
```

`--primary-color` stays required and stays a solid colour. A CSS gradient is an image, not a colour,
so it is invalid in every property that expects one: borders, outlines, SVG strokes, shadows,
`color`, `color-mix()`. Putting a gradient into `--primary-color` would break those silently, with no
error and no way to feature-detect it. `--primary-gradient` is therefore additive: it paints over the
solid on filled surfaces large enough to show a ramp, and everything else keeps the solid.

Surfaces that honour it:

| Component | Surface |
|-----------|---------|
| `Button` | `Filled` `Primary` |
| `Chip` | `Filled` `Primary` |
| `ProgressBar` | fill |
| `Switch` | checked track |
| `Slider` | filled range |
| `Pagination` | active page |
| `OptionPicker` | selected option |
| `Badge` | `Filled` `Primary`, except a `dot` |
| `Alert` | `Primary` accent border |
| `Toast` | `Primary` accent border |
| `TabHeader` | active tab indicator |

Everything else stays flat: focus rings, outlines, icons, primary-coloured text, and small marks
such as the `Timeline` dot, the `Checkbox` tick and the `RadioGroup` dot. Focus rings in particular
stay a solid `--focus-border-color`, because an indicator needs one predictable contrast ratio
against whatever it sits on, not a ramp that is only high-contrast at one end.

Pick a `--primary-color` that reads as the same brand next to the gradient, since the two sit side
by side on a component such as an active `Pagination` item, which has a gradient fill inside a solid
border.

Two things to check when choosing the stops:

- **Contrast.** `--text-on-primary` is one colour sitting on the whole ramp, so keep the stops within
  a similar lightness. A gradient running light to dark fails contrast at one end.
- **Element-sized ramps.** Each surface paints its own full gradient, so a wide `Button` and a narrow
  one show different slices, and a `ProgressBar` ramp shifts as the value grows.
- **Seams in grouped controls.** Where two primary buttons sit against each other, in a `ButtonGroup`
  or a `SplitButton`, each segment paints a full ramp, so the end of one meets the start of the next
  at the seam. A gradient with a vertical axis has no seam to show, since both segments are the same
  height and stretch together:

  ```
  linear-gradient(180deg, #8d5ce8 0%, #5a37b8 100%)   /* one control, no seam */
  linear-gradient(90deg, #6d4bd6 0%, #4b7ed6 100%)    /* visible seam at each segment boundary */
  ```

  If you want a horizontal or diagonal ramp anyway, flatten the grouped case on its own:

  ```css
  .myToolbar [role='group'] {
    --primary-gradient: none;
  }
  ```

To turn the gradient off for part of the app, or for a component whose fill you have overridden
through its own variable (`--slider-range-color`, `--pagination-active-background`), set it back to
`none` on that subtree:

```css
.myPanel {
  --primary-gradient: none;
}
```

## Nested providers and portals

Providers nest: an inner one overrides the variables it declares and inherits the rest, so a differently-themed panel inside a themed app works without restating the whole theme. Only the outermost one sets the page's `<meta name="theme-color">` to its `--primary-color`; an inner one themes its section, not the browser.

A portal breaks that by default, because theme variables travel by CSS inheritance and a portal escapes the subtree. Anything rendered through `BodyEnd` — `Tooltip`, `Dropdown`, `InputDropdown`, `ColorPicker`, `Menu`, `Popover`, `Modal`, `Toast` — therefore carries the nearest provider's variables with it, so an open panel matches the panel that opened it rather than whatever wraps the element it portals into.

