Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions src/components/ThemeProvider/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
### How It Works

`ThemeProvider` manages the active theme by generating CSS custom properties into a `<style>` block in `<head>` and switching themes via a `data-theme` attribute on `<html>`.

At registration time, each theme's CSS variables are pre-computed and cached.
`getThemeStyleSheet()` serializes all registered themes into a single CSS block scoped by `:root[data-theme="<key>"]`.
Switching themes is a single `setAttribute` call rather than N `setProperty` calls.

The active theme key is persisted to `localStorage` under `THEME_STORAGE_KEY` (`"cc-theme"`).
A blocking preflight script (`injectThemePreflight()`) can be dropped into `<head>` to apply the saved theme before first paint.
This avoids a flash of unstyled content on first paint.

### Basic Usage

Wrap your app in `ThemeProvider`.
No other setup is needed for React apps.
The stylesheet is injected automatically on mount.

```tsx
import { ThemeProvider } from '@ianpaschal/combat-command-components';

export const App = () => (
<ThemeProvider>
<YourApp />
</ThemeProvider>
);
```

To lock a specific theme (e.g. in a preview or demo), pass the `theme` prop:

```tsx
<ThemeProvider theme="dark">
<YourApp />
</ThemeProvider>
```

### Built-In Themes

| Key | Display Name | Dark |
|---|---|---|
| `light` | Light | No |
| `dark` | Dark | Yes |
| `daybreak` | Daybreak | No |
| `midnight` | Midnight | Yes |

`SYSTEM_THEME_KEY` (`"__system"`) resolves to `dark` or `light` based on `prefers-color-scheme`.
It is the default when no preference is stored.

### Accessing the Theme in a Component

```tsx
import { useThemeManager } from '@ianpaschal/combat-command-components';

const { key, theme, options, setTheme } = useThemeManager();
```

| Property | Type | Description |
|---|---|---|
| `key` | `string` | The active key, including `"__system"` if no explicit choice was made. |
| `theme` | `Theme` | The resolved `Theme` object. |
| `options` | `SelectOption[]` | All registered themes plus the system option, ready for a `<Select>`. |
| `setTheme` | `(key: string) => void` | Updates the active theme and persists it to localStorage. |

### Registering a Custom Theme

Call `registerTheme` before the app mounts.
Each registered theme is merged on top of a parent (defaults to `light`).

```ts
import { registerTheme } from '@ianpaschal/combat-command-components';

registerTheme('branded', {
displayName: 'Branded',
dark: false,
surface: {
page: { bg: '#f0e8ff' },
card: { bg: '#ffffff', border: '#d8c8f0' },
},
colors: {
accent: { bg: '#7c3aed', text: '#ffffff', focus: '#7c3aed' },
},
});
```

Custom themes registered before `getThemeStyleSheet()` is first called are included in the generated stylesheet.

### SSR / Static Sites (e.g. Astro)

For server-rendered pages, inject the stylesheet and preflight script into `<head>` before any content renders:

```astro
---
import {
getThemeStyleSheet,
injectThemePreflight,
} from '@ianpaschal/combat-command-components';

const themeCSS = getThemeStyleSheet();
---
<head>
<!-- Inject CSS vars for all themes, scoped to :root[data-theme]. -->
<style is:inline set:html={themeCSS}></style>
<!-- Blocking script: reads localStorage and sets data-theme before first paint. -->
<script is:inline set:html={injectThemePreflight()}></script>
</head>
```

The `<style>` tag carries a `data-theme-vars` attribute so the client-side `ThemeProvider` won't inject a duplicate.

### API Reference

#### `ThemeProvider`

| Prop | Type | Default | Description |
|---|---|---|---|
| `theme` | `string` | - | Locks the active theme; overrides user selection and localStorage. |
| `children` | `ReactNode` | - | |

#### `getThemeStyleSheet(): string`

Serializes all registered themes' CSS variables into a single CSS string scoped by `:root[data-theme="<key>"]`.
In a browser context, also injects the result as a `<style data-theme-vars>` element in `<head>` (idempotent).
Returns the CSS string.

#### `injectThemePreflight(defaults?): string`

Returns a self-executing script string that reads `localStorage.getItem("cc-theme")` and sets `data-theme` on `<html>` before first paint.
If no key is stored or the stored key is `"__system"`, it falls back to `prefers-color-scheme`, mapping to the resolved theme keys.

`defaults` is an optional object that overrides the theme keys used when the stored value is `"__system"`:

| Property | Type | Default | Description |
|---|---|---|---|
| `dark` | `string` | `"dark"` | The theme key applied when `prefers-color-scheme: dark` matches. |
| `light` | `string` | `"light"` | The theme key applied otherwise. |

When omitted, `"dark"` and `"light"` are used.

```ts
// Use built-in keys (dark → "dark", light → "light")
injectThemePreflight()

// Map system dark/light to custom registered keys
injectThemePreflight({ dark: 'midnight', light: 'daybreak' })
```

Drop the returned string into a blocking `<script>` in `<head>`.

#### `registerTheme(key, theme, parentKey?)`

Registers a new theme or overrides an existing one.
`theme` is deep-merged onto `parentKey` (Default: `"light"`).
CSS variables are computed and cached immediately.

#### `THEME_STORAGE_KEY`

The localStorage key used to persist the active theme choice (`"cc-theme"`).
Use this if you need to read or write the preference outside of `ThemeProvider`.

#### `SYSTEM_THEME_KEY`

The sentinel value (`"__system"`) that resolves to `"light"` or `"dark"` based on `prefers-color-scheme`.
Passed to `setTheme` to restore automatic OS-based switching.
10 changes: 10 additions & 0 deletions src/components/ThemeProvider/ThemeProvider.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const THEME_STORAGE_KEY = 'cc-theme';
export const SYSTEM_THEME_KEY = '__system';

const VALID_THEME_KEY = /^[a-zA-Z0-9_-]+$/;

export const validateKey = (key: string, context: string): void => {
if (!VALID_THEME_KEY.test(key)) {
throw new Error(`Invalid theme key "${key}" (${context}). Keys must contain only letters, numbers, hyphens, and underscores.`);
}
};
47 changes: 32 additions & 15 deletions src/components/ThemeProvider/ThemeProvider.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,31 @@ import {
import { useStore } from '@tanstack/react-store';

import { light } from './themes/light';
import { SYSTEM_THEME_KEY } from './ThemeProvider.constants';
import { themeContext } from './ThemeProvider.context';
import { themeStore } from './ThemeProvider.store';
import { Theme } from './ThemeProvider.types';
import { buildThemeVars } from './ThemeProvider.utils';

export const SYSTEM_THEME_KEY = '__system';
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

export const useResolvedTheme = (activeKey: string): Theme => {
/**
* Resolves an active theme key to a `Theme` object and a concrete registry key.
* Handles `SYSTEM_THEME_KEY` by mapping it to `"dark"` or `"light"` based on
* `prefers-color-scheme`, and subscribes to OS preference changes.
*
* @param activeKey - The active theme key, which may be `SYSTEM_THEME_KEY`.
* @returns The resolved `Theme` object and the concrete key (never
* `SYSTEM_THEME_KEY`).
*/
export const useResolvedTheme = (activeKey: string): { theme: Theme; resolvedKey: string } => {
const registry = useStore(themeStore);
const [isDark, setIsDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
const [isDark, setIsDark] = useState(() => (
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
));

useIsomorphicLayoutEffect(() => {
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);

useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
Expand All @@ -26,18 +41,20 @@ export const useResolvedTheme = (activeKey: string): Theme => {
}, []);

if (activeKey === SYSTEM_THEME_KEY) {
return isDark ? (registry['dark'] ?? registry['light'] ?? light) : (registry['light'] ?? light);
const resolvedKey = isDark ? 'dark' : 'light';
return {
theme: registry[resolvedKey]?.theme ?? registry['light']?.theme ?? light,
resolvedKey,
};
}
return registry[activeKey] ?? registry['light'] ?? light;
return {
theme: registry[activeKey]?.theme ?? registry['light']?.theme ?? light,
resolvedKey: activeKey,
};
};

/**
* Returns the theme context value from the nearest `ThemeProvider`, exposing
* the active key, resolved `Theme` object, available options, and `setTheme`.
*/
export const useThemeManager = () => useContext(themeContext);

export const useThemeVars = (theme: Theme): void => {
useLayoutEffect(() => {
const vars = buildThemeVars(theme);
for (const [key, value] of Object.entries(vars)) {
document.body.style.setProperty(key, value);
}
}, [theme]);
};
136 changes: 124 additions & 12 deletions src/components/ThemeProvider/ThemeProvider.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,139 @@ import { daybreak } from './themes/daybreak';
import { light } from './themes/light';
import { midnight } from './themes/midnight';
import { DeepPartial } from '../../types';
import { Theme } from './ThemeProvider.types';
import {
SYSTEM_THEME_KEY,
THEME_STORAGE_KEY,
validateKey,
} from './ThemeProvider.constants';
import { Theme, ThemeRegistryEntry } from './ThemeProvider.types';
import { buildThemeVars } from './ThemeProvider.utils';

export const themeStore = new Store<Record<string, Theme>>({
light,
dark,
daybreak,
midnight,
const makeEntry = (theme: Theme): ThemeRegistryEntry => ({
theme,
vars: buildThemeVars(theme),
});

export const registerTheme = (key: string, theme: DeepPartial<Theme>, parentKey?: string): void => {
export const themeStore = new Store<Record<string, ThemeRegistryEntry>>({
light: makeEntry(light),
dark: makeEntry(dark),
daybreak: makeEntry(daybreak),
midnight: makeEntry(midnight),
});

/**
* Registers a new theme or overrides an existing one. The provided theme is
* deep-merged onto the parent (defaults to `light`). CSS variables are computed
* and cached immediately.
*
* @param key - Unique key used to identify and activate the theme.
* @param theme - Partial theme object; missing values are inherited from the
* parent.
* @param parentKey - Key of the theme to inherit from. Defaults to `"light"`.
*/
export const registerTheme = (
key: string,
theme: DeepPartial<Theme>,
parentKey?: string,
): void => {
validateKey(key, 'registerTheme');
themeStore.setState((state) => {
const parent = parentKey ? (state[parentKey] ?? light) : light;
return { ...state, [key]: deepmerge(parent, theme as Theme) };
if (parentKey && !state[parentKey]) {
console.warn(`registerTheme: parent key "${parentKey}" not found for theme "${key}". Falling back to "light".`);
}
const parent = parentKey ? (state[parentKey]?.theme ?? light) : light;
const merged = deepmerge(parent, theme as Theme);
return { ...state, [key]: makeEntry(merged) };
Comment thread
ianpaschal marked this conversation as resolved.
Comment thread
ianpaschal marked this conversation as resolved.
});
};

/**
* Returns the resolved `Theme` object for the given key. Falls back to `light`
* and logs a warning if the key is not registered.
*
* @param key - Key of the registered theme to retrieve.
*/
export const getRegisteredTheme = (key: string): Theme => {
const theme = themeStore.state[key];
if (!theme) {
const entry = themeStore.state[key];
if (!entry) {
console.warn(`Could not find a theme with key ${key}. Will use 'light' instead.`);
return light;
}
return theme;
return entry.theme;
};

/**
* Serializes all registered themes' CSS variables into a single stylesheet
* string, with each theme scoped to `:root[data-theme="<key>"]`. In a browser
* context, also injects or updates a `<style data-theme-vars>` element in
* `<head>` (idempotent).
*
* @returns The generated CSS string.
*/
export const getThemeStyleSheet = (): string => {
const css = Object.entries(themeStore.state).map(([key, { vars }]) => {
const safeKey = key.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const declarations = Object.entries(vars).map(([k, v]) => (
` ${k}: ${v};`
)).join('\n');
return `:root[data-theme="${safeKey}"] {\n${declarations}\n}`;
}).join('\n\n');

if (typeof document !== 'undefined') {
const existing = document.querySelector('style[data-theme-vars]');
if (existing) {
existing.textContent = css;
} else {
const style = document.createElement('style');
style.setAttribute('data-theme-vars', '');
style.textContent = css;
document.head.appendChild(style);
}
}
Comment thread
ianpaschal marked this conversation as resolved.

return css;
};

/**
* Returns a self-executing script string that reads `localStorage` and sets
* `data-theme` on `<html>` before first paint, preventing a flash of unstyled
* content. Also installs a `MutationObserver` to re-apply the theme if
* `data-theme` is removed (e.g. during Astro page transitions). Drop the
* returned string into a blocking `<script>` in `<head>`.
*
* @param defaults - Optional overrides for the theme keys used when the stored
* value is `SYSTEM_THEME_KEY`. Defaults to `{ dark: "dark", light: "light" }`.
*/
export const injectThemePreflight = (
defaults?: { dark?: string; light?: string },
): string => {
const dark = defaults?.dark ?? 'dark';
validateKey(dark, 'injectThemePreflight defaults.dark');
const light = defaults?.light ?? 'light';
validateKey(light, 'injectThemePreflight defaults.light');
return `
(() => {
const applyTheme = () => {
var key = '${SYSTEM_THEME_KEY}';
try {
key = localStorage.getItem('${THEME_STORAGE_KEY}') || '${SYSTEM_THEME_KEY}';
} catch(e) {}
if (key === '${SYSTEM_THEME_KEY}') {
key = window.matchMedia('(prefers-color-scheme: dark)').matches ? '${dark}' : '${light}';
}
document.documentElement.setAttribute('data-theme', key);
};
applyTheme();
new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme' && !document.documentElement.getAttribute('data-theme')) {
applyTheme();
}
});
}).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
})()
`;
Comment thread
ianpaschal marked this conversation as resolved.
};
Loading
Loading