<!-- Source: README.md -->

# @codenhub/theme

Light and dark mode for browser apps, without the boilerplate. `@codenhub/theme` decides which theme is active, remembers the user's choice in `localStorage`, follows the operating system's light or dark setting until the user picks one, keeps open tabs in agreement, and writes the result onto `<html>` for your CSS to style. It has no dependencies, works with any framework or none, and ships no CSS: your stylesheets decide what each theme looks like.

## Installation

```sh
npm install @codenhub/theme
pnpm add @codenhub/theme
yarn add @codenhub/theme
bun add @codenhub/theme
```

## Usage

Style each theme by its name, which the package writes to `<html>` as `data-theme`:

```css
:root,
[data-theme="light"] {
  --background: #ffffff;
  --text: #171717;
}

[data-theme="dark"] {
  --background: #171717;
  --text: #f5f5f5;
}

body {
  background: var(--background);
  color: var(--text);
}
```

Then create the manager once, start it, and wire up a button:

```ts
import { createTheme } from "@codenhub/theme";

const theme = createTheme().init();

document.querySelector("#theme-toggle")?.addEventListener("click", theme.toggle);
```

With no options, there are two themes, `light` and `dark`. `init()` applies the user's saved choice, or else the operating system's setting, and `toggle()` switches themes and saves the choice.

Because this script runs after the browser first paints, a user who chose dark briefly sees the light theme on load. Production apps also inline the script from `getPrePaintScript()` in `<head>` to apply the theme before the first paint; [Preventing the flash](docs/preventing-the-flash.md) shows how.

## Documentation

- [Documentation overview](docs/index.md): How the package works, and a walkthrough from first theme to production setup.
- [Getting started](docs/getting-started.md): A working light and dark toggle, step by step.
- [Preventing the flash](docs/preventing-the-flash.md): Applying the theme before the first paint, in static and server-rendered apps.
- [Frameworks](docs/frameworks.md): React and Tailwind CSS.
- [API reference](docs/reference/index.md): Every export with its full signature.
- [Changelog](docs/changelog/index.md): Changes in each release.

## Requirements

- A browser to apply the theme. On a server, the manager safely does nothing that needs one and reports the default theme.
- Your own CSS for each theme. The package writes the theme's name, an optional class, `color-scheme`, and optional CSS custom properties to `<html>`; it ships no styles.
- TypeScript 5.4 or newer, if you use TypeScript, for the `NoInfer` the types rely on.

## Notes

- `createTheme()` validates its options immediately and throws an `Error` naming the problem, such as a `defaultTheme` that is not one of your themes.
- When the browser blocks `localStorage`, the package logs each kind of failure (read, write, delete) once with `console.error` and keeps working; a choice it could not save lasts until the page closes.

## License

Licensed under the [Apache-2.0](LICENSE) license.

<!-- Source: docs/index.md -->

# Manage Browser Themes

Almost every app that offers a dark mode ends up writing the same code: check `localStorage` for a saved choice, fall back to the operating system's light or dark setting, put a class or attribute on `<html>` so CSS can react, save the choice when the user clicks a toggle, and keep every open tab in agreement. Then it flashes the wrong theme on page load, and the code grows a second copy of itself inside an inline script to fix that.

`@codenhub/theme` is that code, written once. You describe your themes, and it decides which one is active, remembers the user's choice, follows the operating system until the user makes one, and tells the page about it.

## What it does, and what it leaves to you

The package decides **which** theme is active. Your CSS decides what each theme **looks like**.

On every change, it writes the active theme onto the `<html>` element (`document.documentElement`):

```html
<html data-theme="dark" class="theme-dark" style="color-scheme: dark"></html>
```

- `data-theme="dark"`: the theme's name, for CSS selectors such as `[data-theme="dark"]`.
- `class="theme-dark"`: the same name as a `theme-<name>` class, added by default for code that prefers classes (the `className` option changes or removes it). With `darkClass: true`, a `dark` class for Tailwind CSS is added too.
- `color-scheme: dark`: tells the browser to draw its own controls, such as scrollbars and form fields, in dark colors.
- CSS custom properties, such as `--color-primary`, for the tokens you list in `tokenSchema` and give values in JavaScript.

It ships no CSS, no colors, and no toggle button. Those stay in your application, in whatever form you already write them.

## How a theme is chosen

When the page starts, the package picks a theme in this order:

1. **The user's saved choice.** Once the user picks a theme, it is saved in the browser's `localStorage` and wins on every later visit.
2. **The operating system's setting.** Until the user picks one, the theme follows the system's light or dark mode, and changes with it.
3. **The default theme.** If neither is available, such as on a server, the default theme applies: `light`, unless you configure another.

## The walkthrough

These pages build on each other, so reading them in order takes you from an empty page to a production setup:

1. [Getting started](docs/getting-started.md): Install the package and build a working light and dark toggle.
2. [Defining themes](docs/themes.md): Add your own themes, decide how the toggle and the system setting pick between them, and read the current state.
3. [Design tokens](docs/tokens.md): Keep theme colors in JavaScript and change them at runtime.
4. [Saving and syncing](docs/persistence-and-sync.md): How the choice is saved, how tabs stay in agreement, and how to react when the theme changes.
5. [Preventing the flash](docs/preventing-the-flash.md): Apply the theme before the first paint, in a static site or a server-rendered one.
6. [Frameworks](docs/frameworks.md): Use the package with React, and with Tailwind CSS's `dark:` variant.
7. [Troubleshooting](docs/troubleshooting.md): What to check when something does not work.

The [API reference](docs/reference/index.md) lists every export with its full signature. Look things up there; read the walkthrough to learn how they fit together.

## Requirements

- A browser for the theme to show. The package uses `document.documentElement`, `localStorage`, `matchMedia`, `storage` events, and `CustomEvent`, and treats any of them that is missing as absent instead of crashing your page. A `localStorage` or `matchMedia` call that throws, as in a private window, is treated the same way. On a server it does nothing that needs a browser; see [Preventing the flash](docs/preventing-the-flash.md).
- TypeScript 5.4 or newer, if you use TypeScript. The types use `NoInfer`, which older compilers report as `Cannot find name 'NoInfer'`. Plain JavaScript projects need nothing extra.
- No other dependencies, and no framework.

<!-- Source: docs/getting-started.md -->

# Getting Started

This page builds the smallest useful thing: a page with a light and a dark theme, and a button that switches between them. It assumes a project with a bundler, such as [Vite](https://vite.dev/), that resolves `import` statements from `node_modules`.

## 1. Install the package

Install it with your project's package manager:

```sh
npm install @codenhub/theme
```

```sh
pnpm add @codenhub/theme
```

```sh
yarn add @codenhub/theme
```

```sh
bun add @codenhub/theme
```

## 2. Write the CSS for each theme

The package writes the theme's name onto `<html>`, as `data-theme="light"` or `data-theme="dark"`, along with the `color-scheme` property and, by default, a matching `theme-<name>` class. Your stylesheet gives each name its look. CSS custom properties keep that short: define the colors once per theme, and use them everywhere else.

```css
/* styles.css */
:root,
[data-theme="light"] {
  --background: #ffffff;
  --text: #171717;
}

[data-theme="dark"] {
  --background: #171717;
  --text: #f5f5f5;
}

body {
  background: var(--background);
  color: var(--text);
}
```

`:root` gives the page the light colors before any theme is applied, so it never renders with no colors at all.

## 3. Add a button

Any element works. This one has an `id` so the script can find it:

```html
<button id="theme-toggle" type="button">Toggle theme</button>
```

## 4. Create the theme manager

```ts
// main.ts
import { createTheme } from "@codenhub/theme";

import "./styles.css";

const theme = createTheme().init();

document.querySelector("#theme-toggle")?.addEventListener("click", theme.toggle);
```

Three things happen here:

- `createTheme()` creates a **theme manager**, the object that owns the active theme. With no options, it knows two themes, `light` and `dark`. Creating it changes nothing on the page yet.
- `.init()` starts it. It picks the theme to show, the user's saved choice or else the operating system's setting, applies it to `<html>`, and starts listening for system changes. It returns the same manager, so the two calls chain.
- `theme.toggle` switches to the other theme and saves the choice. Every method of the manager can be passed as a callback on its own, as it is here, without wrapping it in an arrow function.

## 5. Try it

Open the page and click the button: the colors switch. Now reload the page. The theme you picked is still there, because `toggle()` saved it to `localStorage`.

To see the operating-system part, clear the saved choice by running this in the browser console:

```js
localStorage.removeItem("app-theme-preference");
```

Reload, then switch your system between light and dark mode. With no saved choice, the page follows along.

## What you have, and what is next

You now have a working theme switch. Two things are still missing for a production app:

- **A flash on load.** Your script runs after the browser has already painted the page once, so a user who chose dark sees a light page for a moment first. [Preventing the flash](docs/preventing-the-flash.md) fixes that with a small inline script.
- **Cleanup**, if the manager lives inside a component that can unmount. Call `theme.destroy()` in its cleanup to stop the manager's listeners. A manager created once for the whole page, as above, never needs it. [Frameworks](docs/frameworks.md) shows where it goes in React.

Next, [Defining themes](docs/themes.md) replaces the two built-in themes with your own.

<!-- Source: docs/themes.md -->

# Defining Themes

[Getting started](docs/getting-started.md) used the two built-in themes. Real apps usually want their own: a dimmer dark theme, a high-contrast one, a brand theme. This page adds them, explains how the toggle and the operating system choose between them, and shows how to read what is active.

## Describe your themes

Pass `themes` to `createTheme()`, keyed by theme name:

```ts
import { createTheme } from "@codenhub/theme";

const theme = createTheme({
  themes: {
    light: { colorScheme: "light" },
    dark: { colorScheme: "dark" },
    dim: { colorScheme: "dark" },
  },
}).init();
```

Each name is what lands in `data-theme`, so the CSS from Getting started gains one more block:

```css
[data-theme="dim"] {
  --background: #2b2b33;
  --text: #e4e4e7;
}
```

`colorScheme` says whether a theme is light or dark. It is required, and it does three jobs: the package writes it to `color-scheme` so browser controls match, the toggle uses it to find the opposite theme, and Tailwind's `dark` class follows it.

`themes` replaces the built-in list rather than adding to it. Keep themes named `light` and `dark`, as above, or tell the package which themes to use instead, as the next sections show.

## Switch to a specific theme

`set()` applies a theme and saves it as the user's choice, the same way the toggle does. Use it for a theme menu:

```ts
theme.set("dim");
```

The names come from your `themes` object, so in TypeScript a misspelled name, such as `theme.set("dimm")`, is a compile error. In JavaScript it throws `Theme is not configured: dimm.`

To forget the user's choice and follow the operating system again, for an "Auto" or "System" menu entry, call `clearPreference()`:

```ts
theme.clearPreference();
```

## Choose the themes the system picks

When the user has not chosen a theme, the package follows the operating system: `light` when it is in light mode, `dark` when it is in dark mode. `systemTheme` changes which of your themes those are:

```ts
const theme = createTheme({
  themes: {
    paper: { colorScheme: "light" },
    ink: { colorScheme: "dark" },
  },
  systemTheme: { light: "paper", dark: "ink" },
  defaultTheme: "paper",
});
```

`defaultTheme` is the theme used when there is no system setting to read, such as on a server or in a browser without `matchMedia`, and before `init()` runs. Because these themes are not named `light` and `dark`, both options are needed here; without them, `createTheme()` throws an error naming the missing theme.

## Decide what the toggle does

A toggle switches between two things, but an app can have more than two themes. `toggle()` decides where to go from the active theme:

1. If the active theme has a `pairedTheme`, it goes there.
2. Otherwise, it goes to the system theme of the opposite color scheme: from a dark theme to `systemTheme.light`, from a light theme to `systemTheme.dark`.

With the three themes from the start of this page, toggling from `light` goes to `dark` and back, and `dim` would toggle to `light`, the light system theme. To send `dim` somewhere else, pair it:

```ts
const theme = createTheme({
  themes: {
    light: { colorScheme: "light" },
    dark: { colorScheme: "dark" },
    dim: { colorScheme: "dark", pairedTheme: "light" },
    sepia: { colorScheme: "light", pairedTheme: "dim" },
  },
});
```

Now `sepia` toggles to `dim`, and `dim` toggles back to `light`. A pair goes one way only: give each theme its own `pairedTheme` when the toggle should return to where it came from.

## Read the current state

The manager answers four questions:

| Member             | Answers                                                                                                                                 |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `theme.name`       | Which theme is active, such as `"dim"`.                                                                                                 |
| `theme.get()`      | Everything about the active theme: `name`, `colorScheme`, `tokens`, and `pairedTheme`.                                                  |
| `theme.preference` | Which theme the user saved, or `null` when nothing is saved, storage cannot be read, or the saved name is no longer one of your themes. |
| `theme.system`     | Which theme the operating system's setting maps to right now, whether or not it is shown.                                               |

`preference` is what makes an "Auto" option possible: a menu shows "Auto" as selected while it is `null`.

```ts
const selected = theme.preference ?? "auto";
```

`preference` reads what is actually saved. While the browser blocks `localStorage`, it stays `null` even after the user picks a theme, although the choice is still applied for as long as the page runs; [Saving and syncing](docs/persistence-and-sync.md#when-storage-fails) covers that case.

`get()` returns a frozen object, and the same object until the theme next changes. That makes it safe to hand to a UI framework as a store snapshot, which [Frameworks](docs/frameworks.md#react) relies on.

## Every option, and its default

All options are optional. An option set to `undefined` gets its default, exactly as an omitted one does, so a wrapper can forward its own optional settings without checking them.

| Option         | Default                                                              | What it controls                                                                                            |
| -------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `themes`       | `{ light: { colorScheme: "light" }, dark: { colorScheme: "dark" } }` | The available themes, keyed by name.                                                                        |
| `defaultTheme` | `"light"`                                                            | The theme used when no system setting can be read, and before `init()`.                                     |
| `systemTheme`  | `{ light: "light", dark: "dark" }`                                   | The themes used for the operating system's light and dark modes.                                            |
| `storageKey`   | `"app-theme-preference"`                                             | The `localStorage` key the user's choice is saved under.                                                    |
| `attribute`    | `"data-theme"`                                                       | The attribute on `<html>` that holds the theme name.                                                        |
| `className`    | `true`                                                               | The class on `<html>`: `true` adds `theme-<name>`, `false` adds none, and a function returns a custom one.  |
| `darkClass`    | `false`                                                              | Whether to add a `dark` class while a dark theme is active, for [Tailwind CSS](docs/frameworks.md#tailwind-css). |
| `tokenSchema`  | none                                                                 | The CSS custom properties token values are written to; see [Design tokens](docs/tokens.md).                      |

A few of these deserve a word:

- **`storageKey`**: Every app on the same domain shares one `localStorage`. If two apps on one domain both use this package, give each its own key, such as `"my-app-theme"`, so they do not overwrite each other's choice.
- **`attribute`**: Any valid HTML attribute name works, one without spaces, quotes, `/`, `>`, or `=`, except `class` and `style`, because writing the theme name there would erase the classes and inline styles your page put on `<html>`. Use `className` to get a class.
- **`className`**: A function receives the theme, including its static tokens, and returns one class name with no spaces. `className: (theme) => theme.name` gives classes like `dark` and `dim` instead of `theme-dark`. A function that returns an empty or space-containing name makes the operation that applied it throw.

## When the configuration is wrong

`createTheme()` checks every option immediately and throws an `Error` describing the first problem it finds, such as a `defaultTheme` or `pairedTheme` that names no configured theme, a `colorScheme` that is neither `"light"` nor `"dark"`, or, while `className` is `true`, a theme name that cannot be part of a class. Nothing reaches the page from invalid options, so a mistake shows up the first time the code runs, not as a theme that silently never applies. The [`createTheme` reference](docs/reference/index.md#createtheme) lists every rule.

Next, [Design tokens](docs/tokens.md) moves theme colors from CSS into JavaScript, for apps that need to change them while running.

<!-- Source: docs/tokens.md -->

# Design Tokens

So far, every color lived in CSS, keyed by `[data-theme]`. That is the simplest setup and often all an app needs. Some apps need theme values in JavaScript too: colors loaded from a JSON config, a brand color the user picks in a settings screen, or a value a canvas chart reads. **Tokens** cover those cases. A token is a named value, such as `primary`, that the package writes to a CSS custom property, such as `--color-primary`, whenever its theme is active.

## Name your tokens

`tokenSchema` lists the tokens and the custom property each one is written to:

```ts
import { createTheme } from "@codenhub/theme";

const theme = createTheme({
  themes: {
    light: { colorScheme: "light", tokens: { primary: "#171717", surface: "#ffffff" } },
    dark: { colorScheme: "dark", tokens: { primary: "#f9fafb", surface: "#171717" } },
  },
  tokenSchema: { primary: "--color-primary", surface: "--color-surface" },
}).init();
```

Each theme's `tokens` holds its values. When `dark` becomes active, the package sets `--color-primary: #f9fafb` and `--color-surface: #171717` on `<html>`, and your CSS uses them like any other custom property:

```css
button {
  background: var(--color-primary);
  color: var(--color-surface);
}
```

In TypeScript, the keys of `tokenSchema` type every `tokens` object, so a misspelled token name is a compile error. Without a `tokenSchema`, tokens are not allowed at all.

Each `tokenSchema` value must be a custom property name a stylesheet can use as written: `--` followed by letters, digits, `-`, `_`, or non-ASCII characters. Anything else, such as `"--"` alone or a name containing a space, throws from `createTheme()`, as does mapping two tokens to the same property.

## Mix tokens with CSS

A theme does not have to set every token. When the active theme leaves a token out, the package removes that property from `<html>`, and whatever your stylesheet says applies instead. Token values are set as inline styles on `<html>`, so when a theme does set one, it wins over a value for the same property in a `[data-theme]` rule, unless that rule uses `!important`.

That lets you keep most values in CSS and move only the ones JavaScript must control.

## Change tokens at runtime

`setTokens()` overrides token values for every theme, which suits a color the user picks:

```ts
const picker = document.querySelector<HTMLInputElement>("#accent-picker");

picker?.addEventListener("input", () => {
  theme.setTokens({ primary: picker.value });
});
```

Overrides win over the themes' own `tokens`, and they stay in place when the theme changes. Each call replaces all previous overrides, so pass every token you want overridden, and pass `{}` to remove them all.

A few rules keep the values predictable:

- **Overrides are not saved.** They last until the page unloads or `destroy()` runs. To keep a user's color, save it yourself and call `setTokens()` again on the next visit.
- **An empty value means "no override".** A token set to `""` or `undefined` falls back to the theme's own value, or to your CSS when the theme sets none. A cleared color input therefore returns to the theme color instead of removing it.
- **Numbers are written as strings.** In JavaScript, `setTokens({ radius: 4 })` writes `"4"`; the TypeScript types accept strings only, so write `"4"` there. Any value that is not a string, a finite number, `undefined`, or `""` throws, as does a token name that is not in `tokenSchema`.
- **Values are not checked as CSS.** A value the browser rejects, such as `"red; color: blue"`, is simply not applied, and the property keeps its previous value, although `get().tokens` still reports the rejected value. Check values that come from user input before passing them in.
- **Before `init()`, overrides wait.** Called before the first theme is applied, or after `destroy()`, `setTokens()` only records the overrides; the next change, usually `init()`, applies them. This keeps an early call from showing the default theme over the one already on the page.

## Read token values

`theme.get().tokens` holds the values JavaScript knows about: the active theme's `tokens` with your overrides on top. It never reads the page.

`theme.readTokens()` also includes tokens defined only in CSS. For each token JavaScript has no value for, it reads the value the browser computed for the custom property on `<html>`:

```ts
theme.setTokens({ primary: "#00c853" });

// With the light theme active:
theme.get().tokens; // { primary: "#00c853", surface: "#ffffff" }
theme.readTokens(); // the same, plus any token the theme left to CSS
```

Reading computed styles can make the browser recalculate layout, so prefer `get().tokens` unless you need the CSS-only values. `readTokens()` skips the read entirely when JavaScript knows every token.

Next, [Saving and syncing](docs/persistence-and-sync.md) covers how the user's choice is saved and how the rest of your code finds out when the theme changes.

<!-- Source: docs/persistence-and-sync.md -->

# Saving and Syncing

The theme switch already remembers the user's choice and follows the operating system. This page explains how that works, what happens when the browser gets in the way, and how the rest of your code can follow along when the theme changes.

## How the choice is saved

`set()` and `toggle()` save the chosen theme's name in `localStorage`, under the key `"app-theme-preference"` unless you set `storageKey`. On the next visit, `init()` finds it and applies it. `clearPreference()` deletes it and returns to the operating system's setting.

Only a name that matches a configured theme counts. If the saved value names a theme you have since removed, the package ignores it, as if nothing were saved.

While a choice is saved, changes to the operating system's light or dark mode are ignored: the user said what they want. They apply again once `clearPreference()` runs.

## When storage fails

Browsers can refuse `localStorage`: a private window may block it, a user may disable site data, or storage may be full. The package never lets that break your page, and each kind of failure (read, write, delete) is logged once with `console.error`, not on every call:

- **A failed read** counts as no saved choice, so the operating system's setting applies, unless the user already picked a theme with `set()` or `toggle()` on this page, which stays.
- **A failed write** keeps whatever was saved before, if anything. The user's new choice still works for as long as the page is open: after `set()` or `toggle()`, the chosen theme stays, and operating-system changes do not override it. On reload, the previously saved theme, or the operating system's setting, applies again.
- **A failed delete** in `clearPreference()` leaves the old choice saved. The page switches to the system theme, but a reload restores the saved choice, and operating-system changes stay ignored while it is there.

`theme.preference` reports the saved choice when it names one of your themes, and `null` otherwise, so in that situation it can disagree with the active theme: it stays `null` when storage cannot be read at all, and keeps the previously saved theme when only saving fails, as with a full quota.

## Keeping tabs in agreement

A user with your app open in two tabs who switches the theme in one expects the other to follow. It does, automatically: every tab whose manager has run `init()` with the same `storageKey` listens for changes to the saved choice. When another tab saves, clears, or replaces it, including with `localStorage.clear()`, each tab picks its theme again from storage and the operating system, as `init()` does. A choice a tab could not save is dropped at that point, because storage no longer agrees with it. The exception is a tab where accessing `window.localStorage` itself throws: it ignores these events and keeps its choice. A tab that can reach `localStorage` but fails to read from it drops its choice like any other.

Only the theme follows. Token overrides from `setTokens()` belong to the tab that set them.

## Reacting to changes

Other parts of your code may need to know when the theme changes: a chart that redraws in new colors, or an icon that shows a sun or a moon. `subscribe()` calls a function after every change and returns a function that stops it:

```ts
const unsubscribe = theme.subscribe(({ name, theme: active, source }) => {
  console.log(`Now showing ${name}, a ${active.colorScheme} theme, because of ${source}.`);
});

// Later, when it is no longer needed:
unsubscribe();
```

The function receives:

- `name`: the active theme's name.
- `theme`: the same object `theme.get()` returns.
- `source`: what caused the change. It is the method that ran, `"init"`, `"set"`, `"toggle"`, `"clearPreference"`, or `"setTokens"`; `"system"` when the operating system's mode changed; or `"storage"` when another tab changed the saved choice.

`source` matters when your app also saves the theme somewhere else, such as a user profile on your server. React only to `"set"`, `"toggle"`, and `"clearPreference"`, the user's own actions. Otherwise every open tab would repeat the same request when it receives the change through `"storage"`.

A few guarantees make subscribers safe to write:

- A subscriber that throws is logged, and the others still run.
- A subscriber that changes the theme itself, such as by calling `set()`, stops the delivery of the change it received: the subscribers after it receive only the newer change, so none of them acts on a theme that is already gone.
- A subscriber added while a change is being delivered receives only later changes.

### From code without access to the manager

Some code cannot import the manager, such as a third-party widget or a separate script. The package also dispatches a `themechange` event on `window` after every change that a subscriber did not replace with a newer one, with the same information in its `detail`. Import the event name and type instead of writing the string yourself:

```ts
import { THEME_CHANGE_EVENT, type ThemeChangeEvent } from "@codenhub/theme";

window.addEventListener(THEME_CHANGE_EVENT, (event) => {
  const { name } = (event as ThemeChangeEvent).detail;
  console.log(`Now showing ${name}.`);
});
```

The event does not say which manager sent it. If a page runs more than one manager, use `subscribe()` on the one you care about.

Next, [Preventing the flash](docs/preventing-the-flash.md) makes the right theme appear before the page is first drawn.

<!-- Source: docs/preventing-the-flash.md -->

# Preventing the Flash

Reload the page from [Getting started](docs/getting-started.md) with the dark theme chosen, and look closely: for a moment, it is light. The browser draws the page as soon as it has the HTML and CSS, and your script, which calls `init()`, runs after that. Until then, `<html>` carries no `data-theme`, so the page shows its default colors.

The fix is a tiny script that runs **before** the first paint. It must be written directly into the page's `<head>`, not loaded from a file, so the browser runs it before drawing anything. `getPrePaintScript()` returns that script as a string. It repeats exactly what `init()` decides, the saved choice, else the operating system's setting, and applies the attribute, classes, `color-scheme`, and theme tokens. When `init()` runs later, the right theme is already there and nothing visibly changes.

## Share the options

The pre-paint script and the manager in your app must agree on every option: if one uses another `storageKey`, they disagree about the saved choice and the flash comes back. Keep the options in one module that both import:

```ts
// src/theme-options.ts
import type { ThemeOptions } from "@codenhub/theme";

export const themeOptions = {
  themes: {
    light: { colorScheme: "light" },
    dark: { colorScheme: "dark" },
  },
  storageKey: "my-app-theme",
} satisfies ThemeOptions;
```

```ts
// src/main.ts
import { createTheme } from "@codenhub/theme";

import { themeOptions } from "./theme-options";

export const theme = createTheme(themeOptions).init();
```

`satisfies ThemeOptions` checks the object against the option types while keeping its exact theme names, so `theme.set()` still accepts only `"light"` and `"dark"`.

Where the script is generated depends on how your HTML is produced.

## In a static site

A single-page app built with Vite has one `index.html`, written at build time. `createTheme()` is safe to call outside a browser, so a small plugin in `vite.config.ts` can generate the script during the build and put it at the top of `<head>`:

```ts
// vite.config.ts
import { createTheme } from "@codenhub/theme";
import { defineConfig } from "vite";

import { themeOptions } from "./src/theme-options";

export default defineConfig({
  plugins: [
    {
      name: "theme-pre-paint",
      transformIndexHtml() {
        return [
          {
            tag: "script",
            children: createTheme(themeOptions).getPrePaintScript(),
            injectTo: "head-prepend",
          },
        ];
      },
    },
  ],
});
```

Run the dev server, view the page source, and the script is the first thing in `<head>`. Other build tools have an equivalent step for editing the HTML; what matters is that the string ends up inside a `<script>` element in `<head>`, before your stylesheets.

## In a server-rendered app

When a server builds each page, generate the script there and write it into the HTML it sends. With any Node.js server that writes HTML as a string:

```ts
import { createTheme } from "@codenhub/theme";

import { themeOptions } from "./theme-options";

// The script depends only on the options, so build it once.
const prePaintScript = createTheme(themeOptions).getPrePaintScript();

function renderPage(body: string): string {
  return `<!doctype html>
<html lang="en">
  <head>
    <script>${prePaintScript}</script>
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>${body}</body>
</html>`;
}
```

The script is safe to place inside `<script>` as is: theme names and token values cannot close the element or break out of it.

In a React framework that renders `<html>` itself, such as Next.js, put it in the root layout:

```tsx
// src/app/layout.tsx
import { createTheme } from "@codenhub/theme";

import { themeOptions } from "../theme-options";

const prePaintScript = createTheme(themeOptions).getPrePaintScript();

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
      </head>
      <body>{children}</body>
    </html>
  );
}
```

`suppressHydrationWarning` is needed because the script changes `<html>` before React loads: React finds a `data-theme`, class, and `style` the server did not render, and would warn that they do not match. The difference is intended. The flag silences that warning on `<html>` only, not on its children.

## What the server knows

On a server there is no `localStorage` and no operating-system setting, so the manager does nothing there that needs a browser: it applies nothing, saves nothing, listens to nothing, and `theme.system` reports `defaultTheme`. Every method is still safe to call; none throws for a missing `window` or `document`.

That means the server cannot know which theme the user chose, and the HTML it sends carries none. The pre-paint script fills it in before the first paint, which is enough for most apps. If your server must render the right theme itself, for example because it renders theme-specific markup, it has to learn the choice from the request, typically from a cookie your app sets when the theme changes. That logic is yours to write; keep its theme names, attribute, and classes aligned with the same options.

## Content Security Policy

If your site sends a Content Security Policy whose `script-src` does not include `'unsafe-inline'`, the browser blocks the inline script, the flash returns, and the only sign is an error in the console. Allow the script in one of two ways:

- **Nonce:** render the element as `<script nonce="…">`, using the per-request nonce your policy already names.
- **Hash:** add `'sha256-…'` of the script text to `script-src`. The script is the same for the same options, so compute the hash once at build time, for example with `createHash("sha256").update(script).digest("base64")` from `node:crypto`. Insert the string exactly as returned: any change, even whitespace around it inside the element, changes the hash.

## What the script does not include

Token overrides from `setTokens()` exist only in the running page, so the script cannot know them; it applies the themes' own `tokens`. Overrides your app saves itself appear once your code calls `setTokens()` again.

Next, [Frameworks](docs/frameworks.md) shows how to use the manager inside React components and with Tailwind CSS.

<!-- Source: docs/frameworks.md -->

# Frameworks

The package has no framework adapter because it does not need one: a manager is a plain object, and its methods can be passed anywhere as callbacks. This page shows the patterns for React and for Tailwind CSS. The same ideas carry over to other frameworks: create one manager for the page, start it once in the browser, and subscribe to it wherever the UI shows the theme.

## React

### One manager for the whole app

Create the manager once, in its own module, and import it wherever you need it. Do not create it inside a component: each render would create a new one.

```ts
// src/theme.ts
import { createTheme } from "@codenhub/theme";

import { themeOptions } from "./theme-options";

export const theme = createTheme(themeOptions).init();
```

In an app that only runs in the browser, such as one built with Vite, calling `init()` right there is fine. Server-rendered apps start it differently; see [With server rendering](#with-server-rendering) below.

### Show the active theme in a component

React re-renders a component when its data changes only if it knows about the change. `useSyncExternalStore` connects React to an outside source like the manager: `theme.subscribe` tells React when to re-read, and `theme.get` is what it reads. `get()` returns the same object until the theme changes, which is exactly what React expects.

```tsx
import { useSyncExternalStore } from "react";

import { theme } from "./theme";

export function useActiveTheme() {
  return useSyncExternalStore(theme.subscribe, theme.get);
}

export function ThemeToggle() {
  const active = useActiveTheme();
  return (
    <button type="button" onClick={theme.toggle}>
      {active.colorScheme === "dark" ? "Switch to light" : "Switch to dark"}
    </button>
  );
}
```

### With server rendering

In a server-rendered app, such as Next.js or Remix, the same module also runs on the server, where there is nothing to start, and React first renders the page in the browser by **hydrating** the server's HTML: it expects the first browser render to match what the server rendered.

Two changes handle that. Start the manager from an effect, which runs only in the browser, and give `useSyncExternalStore` a third argument, `getServerSnapshot`, that returns the theme the server rendered with:

```tsx
// src/theme.ts
import { createTheme } from "@codenhub/theme";
import { useSyncExternalStore } from "react";

import { themeOptions } from "./theme-options";

export const theme = createTheme(themeOptions);

// Taken before init(), so it is the default theme, the one the server renders with.
const serverSnapshot = theme.get();

export function useActiveTheme() {
  return useSyncExternalStore(theme.subscribe, theme.get, () => serverSnapshot);
}
```

```tsx
// In your root client component:
useEffect(() => {
  theme.init();
}, []);
```

Hydration then renders the default theme, matching the server, and the component updates to the user's theme right after `init()` runs. The page itself never shows the wrong colors: the [pre-paint script](docs/preventing-the-flash.md) already applied the right theme to `<html>`, and only components that read `useActiveTheme()` briefly see the default. Calling `init()` more than once does nothing after the first call, so React's development double-run of effects is harmless.

If your server resolves the theme per request instead, such as from a cookie, it renders with that theme, so `getServerSnapshot` must return that same theme, passed to the browser with the page.

### Cleaning up

A manager created once for the whole page lives as long as the page, and needs no cleanup. A manager that belongs to something shorter-lived, such as a component that mounts and unmounts or a test, must be stopped with `destroy()`:

```tsx
useEffect(() => {
  const preview = createTheme(previewOptions).init();
  return () => preview.destroy();
}, []);
```

`destroy()` stops listening to the operating system and other tabs, removes every subscriber, and drops token overrides. It leaves `<html>` and the saved choice as they are, so the page does not change when the component unmounts. Pass `{ revertDom: true }` to also remove the attribute, classes, `color-scheme`, and token properties the manager wrote, leaving no trace. A destroyed manager can be started again with `init()`.

## Tailwind CSS

Tailwind's `dark:` variant, as in `class="bg-white dark:bg-black"`, applies its styles when it decides the page is dark. Set `darkClass: true`, and the package adds a `dark` class to `<html>` whenever the active theme's `colorScheme` is `"dark"`:

```ts
const theme = createTheme({ darkClass: true }).init();
```

Then tell Tailwind to look for that class instead of the operating-system setting, which it uses by default. In Tailwind CSS v4, add this to your main CSS file:

```css
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
```

In Tailwind CSS v3, set it in `tailwind.config.js`:

```js
export default {
  darkMode: "class",
  // …the rest of your configuration
};
```

Because the `dark` class follows `colorScheme` rather than a theme's name, every dark theme you define, such as a `dim` theme, gets `dark:` styles too. `darkClass` works alongside `className`, so `<html>` can carry both `dark` and `theme-dim`, for custom CSS that only the `dim` theme needs.

Next, [Troubleshooting](docs/troubleshooting.md) collects the problems people hit most often.

<!-- Source: docs/troubleshooting.md -->

# Troubleshooting

Start with the symptom you see. Each entry says how to confirm the cause, then how to fix it.

## Nothing changes when the theme switches

Open the browser's developer tools and inspect the `<html>` element while you click the toggle.

- **`data-theme` changes, but the page does not.** The package is working; your CSS has no rules for that theme name. Add a `[data-theme="<name>"]` rule for every theme, as in [Getting started](docs/getting-started.md#2-write-the-css-for-each-theme). If you set `attribute`, use that attribute name in your selectors instead.
- **`data-theme` never appears.** If you set `attribute`, look for that attribute instead. If it never appears either, `init()` was not called, or the code calling it never ran. `createTheme()` alone does nothing to the page. Check the console for an error thrown earlier, such as one from `createTheme()` about invalid options.
- **Tailwind `dark:` styles do not apply.** Check for `darkClass: true` in the options, a `dark` class on `<html>`, and the Tailwind configuration from [Frameworks](docs/frameworks.md#tailwind-css). Without that configuration, Tailwind follows the operating-system setting and ignores the class.

## The wrong theme flashes when the page loads

The pre-paint script is missing, blocked, or out of step with your app. In order:

1. View the page source, not the developer tools' element view, and check that the script is inside `<head>`. If it is not there, set it up as in [Preventing the flash](docs/preventing-the-flash.md).
2. Check the console for a Content Security Policy error. If there is one, allow the script with a nonce or a hash; see [Content Security Policy](docs/preventing-the-flash.md#content-security-policy).
3. Check that the script and your app's manager use the same options, especially `storageKey` and `themes`. Importing both from one module keeps them aligned.

## The choice is forgotten after a reload

- **The browser blocks storage**, as some private windows do. The console shows a `[theme]` error the first time saving fails. The choice still works until the page closes; there is nowhere to keep it longer. See [When storage fails](docs/persistence-and-sync.md#when-storage-fails).
- **Another app on the same domain uses the same `storageKey`** and overwrites it. Give your app its own `storageKey`.
- **The saved theme no longer exists**, because it was renamed or removed from `themes`. The package ignores a saved name it does not recognize, so the user falls back to the operating-system setting.

## The theme ignores the operating system

The user has a saved choice, which always wins. `theme.preference` shows it. Call `theme.clearPreference()`, from an "Auto" or "System" option in your UI, to follow the operating system again.

## `createTheme()` throws

The options are invalid, and the error message names the problem. The most common ones:

- **A missing theme.** Custom `themes` without `light` and `dark` also need `defaultTheme` and `systemTheme` naming themes you defined; see [Choose the themes the system picks](docs/themes.md#choose-the-themes-the-system-picks).
- **A `pairedTheme`, `defaultTheme`, or `systemTheme` naming a theme that is not in `themes`.** Check the spelling.
- **A theme name that cannot be a class.** While `className` is `true`, each name becomes part of a `theme-<name>` class, so it cannot contain spaces. Rename the theme, or set `className` to `false` or a function.
- **`tokens` without a `tokenSchema`, or a token not listed in it.** Every token needs an entry in `tokenSchema`; see [Design tokens](docs/tokens.md).

## TypeScript reports `Cannot find name 'NoInfer'`

Your TypeScript version is older than 5.4, which added `NoInfer`. Update TypeScript in your project. Turning on `skipLibCheck` hides the error, but then the types also accept theme names you did not configure.

## A React hydration warning mentions `<html>`

React found the `data-theme`, class, or `style` the pre-paint script added. That difference is expected; add `suppressHydrationWarning` to your `<html>` element, as shown in [Preventing the flash](docs/preventing-the-flash.md#in-a-server-rendered-app).

<!-- Source: docs/changelog/0.2.1.md -->

# 0.2.1

## Added

- The package declares `"sideEffects": false`, so a bundler can drop it from a build that imports it but uses none of its exports.
- A documentation walkthrough, from a first light and dark toggle to a production setup, with CSS examples, a table of every option and its default, a Vite example for the pre-paint script, React and Tailwind CSS setup, and a troubleshooting page.

## Changed

- Documentation pages are reorganized around the walkthrough: `docs/tokens-and-persistence.md` is replaced by `docs/tokens.md` and `docs/persistence-and-sync.md`, and `docs/ssr-and-pre-paint.md` by `docs/preventing-the-flash.md` and `docs/frameworks.md`. Update any link that pointed at the old pages.

<!-- Source: docs/changelog/0.2.0.md -->

# 0.2.0

## Added

- Theme names are inferred from the keys of `themes`, so `defaultTheme`, `systemTheme`, `pairedTheme`, and `set()` reject unconfigured names at compile time.
- `theme.name`, `theme.preference`, and `theme.system` getters read the active name, the stored preference, and the theme the OS preference maps to.
- `setTokens(tokens)` replaces the runtime token overrides; pass `{}` to remove them. Changes it makes report `source: "setTokens"`. Called before `init()` or any other change, it only records the overrides, so it cannot replace the theme the pre-paint script applied.
- `readTokens()` returns every token, completed with computed CSS values at the time of the call.
- Changes made in another tab report `source: "storage"`, so code that mirrors the preference elsewhere can tell them from local changes.
- The `ThemeChangeEvent` type types the `themechange` window event, whose `detail` was untyped.

## Changed

- `themes` is an object keyed by theme name instead of an array: `{ light: { colorScheme: "light" } }` replaces `[{ name: "light", colorScheme: "light" }]`.
- Methods are bound to their manager, so `theme.toggle` and `theme.subscribe` can be passed as callbacks directly.
- `get()` returns the same frozen object until the next change and never reads the DOM, so it can serve as a store snapshot such as `useSyncExternalStore(theme.subscribe, theme.get)`. Its `tokens` holds static and runtime tokens only; call `readTokens()` for tokens defined only in CSS.
- `isTailwindCss` is renamed `darkClass`, and `shouldApplyClass` is renamed `className`.
- `Theme`, `ThemeOptions`, and the other generic types take the theme name union as their first type parameter, before the token schema.
- When another tab sets, clears, or invalidates the preference, every tab re-resolves its theme and notifies, even when the active theme stays the same, so a settings screen sees the preference change.
- Listener errors are logged as `[theme] Error in theme change listener:`, matching the package's other messages.
- A storage operation that fails is logged the first time it fails for a manager, not on every call, so a page with blocked storage no longer logs an error each time `theme.preference` is read.
- A theme or runtime token value that is not a string, a finite number, or `undefined` throws; a number is written as its string, which `get().tokens` now reports too. Strings are not parsed as CSS, so one the browser rejects is not applied but still appears in `get().tokens`; see [Change tokens at runtime](docs/tokens.md#change-tokens-at-runtime).
- A runtime token set to `undefined` counts as omitted, so `setTokens({ primary: undefined })` keeps the theme's static `primary` instead of removing it.
- A theme or runtime token set to `""` counts as omitted. The browser removed the property while `get().tokens` and `readTokens()` reported `""`; now an emptied override falls back to the theme's static token, and `readTokens()` reports that token, or the value CSS gives the property when the theme has none.
- `tokens` and `setTokens()` are rejected at compile time when `createTheme()` infers its types from options without a `tokenSchema`, since they always threw. `Theme` and `ThemeOptions` keep their broad default schema, so code typed with them, such as a generic wrapper, still compiles them and gets the runtime error.
- `createTheme()` throws `Theme options must be an object.` for options such as `null`, instead of an unrelated `TypeError`.
- `createTheme()` throws for an `attribute` containing NUL, which `setAttribute()` rejects, and for an `attribute` of `class` or `style` in any letter case, which replaced every class or inline style the page put on `<html>` with the theme name. To apply the theme name as a class, use `className: (theme) => theme.name`.
- A `tokenSchema` value must be a custom property name a stylesheet can use without escapes, such as `--color-primary`. `"--"` alone, which browsers ignored, and names with a space or `;` now throw from `createTheme()`. Two keys mapping to the same property also throw: applying a theme wrote one key's value and removed it for the other, so the pre-paint script and `init()` disagreed.

## Removed

- The standalone `getPrePaintScript(options)` export. Use `createTheme(options).getPrePaintScript()`, which is safe to call on the server.
- The `LIGHT_THEME` and `DARK_THEME` exports; the built-in themes are the default `themes` value.
- `getStored()`, `getSystem()`, and `get({ skipComputed })`; use `preference`, `system`, and `get()` with `readTokens()`.
- The `tokens` parameter of `init()`, `set()`, and `toggle()`; use `setTokens()`.

## Fixed

- Calling `theme.toggle` as an event listener no longer throws, and no longer treats the event as token overrides.
- A snapshot taken before a theme change no longer reports the new theme's computed tokens under the old theme's name.
- The pre-paint script applies the OS theme when `localStorage` access throws, applies `defaultTheme` when `matchMedia` is missing as `init()` does, and replaces a server-rendered theme class instead of adding a second one.
- `localStorage.clear()` in another tab now applies the system theme.
- An option set to `undefined`, such as `storageKey: props.storageKey` forwarded by a wrapper, uses its default instead of making `createTheme()` throw. The option, token value, and `destroy()` option types accept `undefined` under TypeScript's `exactOptionalPropertyTypes` too.
- A token named after an `Object.prototype` member, such as `constructor`, is handled as an ordinary token instead of writing the inherited function to the DOM.
- A `systemTheme` that is not an object, or a `darkClass` that is not a boolean, throws from `createTheme()` instead of being silently accepted.
- The pre-paint script falls back as for an unknown name when the stored value is an `Object.prototype` property such as `toString`, and applies a configured theme named `__proto__`. ([#345](https://github.com/codenhub/codenhub/issues/345))
- Invalid options, such as a `defaultTheme` naming an unconfigured theme, throw from `createTheme()` instead of reaching the page through the pre-paint script. ([#364](https://github.com/codenhub/codenhub/issues/364))
- A subscriber or `themechange` listener that changes the theme, such as by calling `set()` or `setTokens()`, no longer leaves the subscribers and `themechange` listeners after it with the superseded change. One that calls `destroy()` ends the delivery too, and a subscriber added during a delivery no longer receives the change being delivered.
- `storage` events for `sessionStorage`, fired when a same-origin frame in the tab changes it, are ignored. A frame calling `sessionStorage.clear()` re-applied the theme with `source: "storage"`, and discarded a choice that could not be stored.
- When storing fails, an explicit choice made with `set()` or `toggle()`, before or after `init()`, is no longer overridden by `init()` or the next OS color-scheme change.
- The `themechange` event is built with the window's own `CustomEvent`, so `init()` and other changes no longer throw when the global `CustomEvent` belongs to another realm, as in a hand-built jsdom setup.

## Security

- Theme names, option values, and token values can no longer close the inline `<script>` element or open an HTML comment in the pre-paint script.

<!-- Source: docs/changelog/0.1.2.md -->

# 0.1.2

## Added

- A generated API reference, `docs/reference/index.md`, covering every exported symbol from its TSDoc directly — regenerated on every release instead of hand-maintained separately, so it can't drift out of sync with the source.

## Changed

- Documentation split from one page, `docs/reference.md`, into three focused ones: `docs/index.md` (setup and configuration), `docs/tokens-and-persistence.md` (token resolution, `skipComputed`, persistence, cross-tab sync), and `docs/ssr-and-pre-paint.md` (`getPrePaintScript()`, SSR fallback, Tailwind's `isTailwindCss`, `shouldApplyClass`, `destroy({ revertDom: true })`), plus the generated reference above. Update any link that pointed at `docs/reference.md`.
