A theme is a value in the configuration rather than a stylesheet: you state a palette, it is
resolved against the built-in one it extends, and the provider applies it as one class on the
document root and a block of `--ashee-*` variables. [Theming](/concepts/theming) states that
model. This page is the work, in the order you do it: declare, use, persist, check.

## Declare the palette

One object under `color`, keyed by the name you want to write everywhere else:

```ts
// asheeui.config.ts
import type { ExternalConfig } from "@asheeui/web";

const config: ExternalConfig = {
  color: {
    midnight: {
      extends: "dark",
      background: "#0b0b12",
      primary: "#8b5cf6",
      border: "#2d2a45",
    },
  },
};

export default config;
```

`midnight` is your own string, and it needs no declaration: the palette key, `defaultTheme` and
`setTheme` all accept it as it is. The theme's *existence* is checked at runtime, when the
provider renders, so a typo in `defaultTheme` throws there rather than failing to compile, the
name compiles because a theme name is an ordinary string, and the provider rejects one that names
no palette in the configuration.

Three rules keep a custom theme short:

- **`extends`** is `"light"` or `"dark"`, and names the built-in palette everything else comes
  from. A theme that extends `dark` inherits the consumer's `dark`, so an application that
  restated `color.dark.primary` gets its own value here rather than the library's.
- **A role you do not state is inherited.** The palette has ten roles, and each one becomes a
  variable the framework reads.
- **The name is the class.** `midnight` becomes `.theme-midnight` on `<html>`, which is the
  only thing that makes the block apply, see [Check that it applied](#check-that-it-applied).

The ten roles, and what each one paints:

| Key | Variable | What it paints |
| --- | --- | --- |
| `background` | `--ashee-background` | The page and its surfaces |
| `foreground` | `--ashee-foreground` | Body text |
| `primary` | `--ashee-primary` | Actions, accents, the chosen row |
| `secondary` | `--ashee-secondary` | Cards, panels, alternating rows |
| `border` | `--ashee-border` | Rules and input outlines |
| `danger` | `--ashee-danger` | Errors and destructive actions |
| `warning` | `--ashee-warning` | Caution |
| `success` | `--ashee-success` | Confirmation |
| `scrollbarThumb` | `--ashee-scrollbar-thumb` | The scrollbar's handle |
| `scrollbarTrack` | `--ashee-scrollbar-track` | The scrollbar's track |

### The trap: a theme with no `extends`

A custom theme that states no `extends` inherits the application's default family: `dark` when
`defaultTheme` is `"dark"`, and `light` in every other case, including `"system"`, and
including a `defaultTheme` that names another custom theme. So a product that follows the
system and declares a custom theme without `extends` gets a light-based palette: white
backgrounds and dark text, in the middle of a dark application, at night.

State `extends` on every custom theme unless light is genuinely what you mean.

### What the theme is checked for

The object is validated once, when a provider mounts, and a mistake is reported rather than
silently replaced:

- a colour value that is not a string throws, naming the theme and the role;
- a role name that is not one of the ten warns in development and changes nothing, which is what
  a typo looks like;
- an `extends` other than `"light"` or `"dark"` throws, because there is no other palette to
  inherit;
- an `extends` on `light` or `dark` themselves is reported as an unknown key, because those are
  the palettes everything else inherits and there is nothing above them.

The full set of rules is in [Configuration](/concepts/configuration).

## Use it

`defaultTheme` states what a reader with no stored choice sees:

```ts
const config: ExternalConfig = {
  defaultTheme: "midnight",
  color: {
    midnight: { extends: "dark", primary: "#8b5cf6" },
  },
};
```

`"system"` is the other useful value: it follows `prefers-color-scheme` and keeps following it
while the reader changes it. Either way, a stored choice wins over this one on later visits,
that is [Persist it](#persist-it).

### The switch control

`useTheme` reads the controller and re-renders when the selection changes:

```tsx
import { Button, HStack, useTheme } from "@asheeui/web";

export function ThemePicker() {
  const { theme, setTheme } = useTheme();

  return (
    <HStack gap="sm" wrap>
      {(["light", "dark", "midnight", "system"] as const).map((choice) => (
        <Button
          key={choice}
          size="sm"
          variant={theme === choice ? "solid" : "bordered"}
          onClick={() => setTheme(choice)}>
          {choice}
        </Button>
      ))}
    </HStack>
  );
}
```

`theme` is the selection and `resolvedTheme` is what it resolved to, so a control that shows an
icon needs both: while the selection is `"system"`, only `resolvedTheme` says which palette is
on the screen. `availableThemes` is a `readonly ThemeName[]`, the same type `setTheme` takes,
so a picker built from that list passes the chosen name straight back with no cast, exactly as
the written list above does.

### Outside React

`themeController` is the same operations as an object, for the places a hook cannot go, a
keyboard shortcut, an effect that reacts to a route, a script:

```ts
import { themeController } from "@asheeui/web";

themeController.setTheme("midnight"); // choose it, and persist the choice
themeController.toggleTheme(); // advance to the next registered name
themeController.getSelection(); // "midnight", or "system"
themeController.getResolvedTheme(); // "midnight", never "system"
```

`toggleTheme` cycles the registered names and skips `"system"`, because "follow the system" is
not a palette. A two-state control that should offer it is `setTheme("system")` and
`setTheme("midnight")` explicitly.

### The trap: a nested provider adds, never removes

There is one controller and one stylesheet for the document, and every provider configures them
with the themes *its* configuration declares. A provider mounted below the application's own, a
page that demonstrates its own configuration, a preview panel, therefore states the built-ins
and knows nothing about `midnight`.

That is allowed, and it cannot take your theme away: the registered names are unioned, so a
nested provider can add a palette but never remove one, and the stylesheet is rebuilt from the
union rather than written once, so `.theme-midnight` keeps its variables whichever provider
reached the stylesheet first.

Two more rules follow from the same direction, an outer provider's configuration is the
application's, a nested one is a visitor:

- **A nested provider's `defaultTheme` does not become the document's default.** The outermost
  provider owns that, so a preview panel's own default cannot replace what a first visit to the
  application sees.
- **A nested provider emits no pre-paint script.** Only the application's provider emits the
  script that writes the parse-time class, the `color-scheme` and the stylesheet, so a nested
  configuration cannot paint its own theme over the application's before hydration.

[What the controller puts on the page](/concepts/theming#what-the-controller-puts-on-the-page)
states the mechanics, and [Theming](/concepts/theming#server-rendering) states which provider
writes the script.

## Persist it

The choice is stored in `localStorage` under `ashee-theme`, as the selection itself, a theme
name, or `"system"`:

| Fact | Value |
| --- | --- |
| The key | `ashee-theme`, exported as `THEME_STORAGE_KEY` |
| What it holds | The selection: a theme name, or `"system"` |
| Who writes it | `setTheme`, and nothing else |
| When it wins | Whenever it names a theme this build declares |

Two consequences follow from who writes it, `setTheme`, and nothing else:

- **`defaultTheme` is never stored.** Configuring a provider reads the stored value and applies
  it; it does not write one. A reader who has not chosen keeps seeing `defaultTheme`, which is
  what makes a change to it reach them.
- **A stored value is validated on every load.** A name that this build does not declare, a
  theme you renamed, a theme removed in a release, a value another application on the same
  origin wrote, is dropped, `defaultTheme` is applied, and the entry is removed. That is the
  self-healing path, so a stale preference is a single wrong-looking visit and not a support
  ticket.

Renaming a theme is therefore a one-line change: the old name stops validating, its entry is
cleared, and readers land on `defaultTheme`.

The key is exported if you need it yourself, and hand-clearing a preference is the same
operation the stale check performs:

```ts
import { THEME_STORAGE_KEY } from "@asheeui/web";

localStorage.removeItem(THEME_STORAGE_KEY); // the next load is a first visit
```

## Check that it applied

"It looks dark" is not a check: a theme that failed to resolve looks like the default theme,
which may also be dark. There are three things to look at, and each rules out a different
failure.

1. **The class.** `<html>` carries `theme-midnight` and no other `theme-*` class. This is what
   selects the palette.
2. **The variable.** `--ashee-primary` computes to the value the theme states. This is what
   makes the components change colour.
3. **The block.** The `#ashee-theme-vars` stylesheet contains `.theme-midnight`. Without one the
   class selects nothing, and the page keeps whatever the previous block said.

```js
const root = document.documentElement;

root.className; // "… theme-midnight"
getComputedStyle(root).getPropertyValue("--ashee-primary").trim(); // "#8b5cf6"
document.getElementById("ashee-theme-vars").textContent.includes(".theme-midnight"); // true
```

Read the three together and the symptom says what happened:

| What you see | What it means |
| --- | --- |
| `theme-light` on `<html>` | The name never reached a provider: the class is the default, so check which config object the provider was given, and whether the stored selection names a theme that no longer exists |
| `theme-midnight` on `<html>`, values unchanged | The class is right and the block is missing or overridden, two providers registered `midnight`, and the later registration's palette is the one in the stylesheet |
| The provider throws `defaultTheme: "midnight" is not a valid value` | The name compiles, because a theme name is an ordinary string, but this configuration declares no such palette: `color` has no such theme, or the object the provider received is not the one you edited |
| The console warns `color.midnight.primray: unknown key`, and one colour does not change | A role name is misspelt. The ten are in the table above, and the warning is development-only |
| A reader still sees the old theme after you changed `defaultTheme` | Their stored selection still names a theme this build declares, and a stored choice wins over the default. Clearing `THEME_STORAGE_KEY` in their browser shows what a first visit sees |

One more thing the three checks do not show: the applied theme is also written to the root's
`color-scheme`, which is what turns the browser's own surfaces, scrollbars, form controls, the
caret, with the theme. The pre-paint script writes it on first load and the controller writes it
again on every apply, so a switch made after the first paint moves those surfaces with the
palette rather than leaving them on the scheme the page opened with.

## Next

- [Theming](/concepts/theming) for the model, the `useTheme` fields and server rendering.
- [Configuration](/concepts/configuration) for where the object goes and how every other value
  resolves.
- [A data table](/guides/data-table) for the screen these colours are most likely to be worn by.
