diff --git a/apps/docs/content/docs/getting-started.mdx b/apps/docs/content/docs/getting-started.mdx
index 116e5557..d229a26e 100644
--- a/apps/docs/content/docs/getting-started.mdx
+++ b/apps/docs/content/docs/getting-started.mdx
@@ -21,55 +21,19 @@ Inside this monorepo, use a workspace dependency instead.
}
```
-## Render a component
-
-Apply the Luke UI theme class near the top of your app. Components use it to resolve design-token
-CSS variables and scoped reset rules.
-
-```tsx
-import lukeUiStyles from '@luke-ui/react/stylesheet.css?url';
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { Text } from '@luke-ui/react/text';
-
-export function App() {
- return (
- <>
-
-
- Hello world
-
- >
- );
-}
-```
-
-## Minimal example
+## Choose a theme
-For small demos, import the stylesheet and render a component directly.
+Import the shared stylesheet and one bundled theme. Machined edge is the default tactile theme. ELMO
+is the flatter alternative.
-```tsx
-import lukeUiStyles from '@luke-ui/react/stylesheet.css?url';
-import { Text } from '@luke-ui/react/text';
+See [Theming overview](/docs/theming) to compare the bundled themes and colour modes.
-export function Example() {
- return (
- <>
-
- Hello world
- >
- );
-}
-```
-
-## Manual root class
-
-Use `themeRootClassName` when you need to attach the theme class yourself.
+## Render a component
-```tsx
-import { themeRootClassName } from '@luke-ui/react/theme';
+Apply the Luke UI root class and the chosen theme identity class near the top of your app.
+Components use them to resolve semantic CSS variables and scoped reset rules.
-;
-```
+
## Next steps
@@ -79,3 +43,4 @@ import { themeRootClassName } from '@luke-ui/react/theme';
- Read [Combobox Field](/docs/components/forms/combobox-field).
- Read [Text](/docs/components/typography/text).
- Read [Icon](/docs/components/visuals/icon).
+- Read [Applying a theme](/docs/theming/applying).
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
index 5ba9c9a5..9e8c5ac8 100644
--- a/apps/docs/content/docs/meta.json
+++ b/apps/docs/content/docs/meta.json
@@ -1,4 +1,4 @@
{
"title": "Documentation",
- "pages": ["index", "getting-started", "components"]
+ "pages": ["index", "getting-started", "theming", "components"]
}
diff --git a/apps/docs/content/docs/theming/applying.mdx b/apps/docs/content/docs/theming/applying.mdx
new file mode 100644
index 00000000..5716dc3f
--- /dev/null
+++ b/apps/docs/content/docs/theming/applying.mdx
@@ -0,0 +1,52 @@
+---
+title: Applying a theme
+description: Load Luke UI styles and apply theme identity and colour mode correctly.
+---
+
+## Load the CSS entrypoints
+
+Import the shared component stylesheet and exactly one theme stylesheet. Importing one bundled theme
+does not pull in the other.
+
+Use `@luke-ui/react/themes/elmo.css` instead when ELMO is the application theme.
+
+## Apply theme identity
+
+Apply `themeRootClassName` and the matching identity class near the application root.
+
+
+
+The bundled identity constants are `machinedEdgeThemeClassName` and `elmoThemeClassName`. Theme
+identity is not nestable. Choose one identity for an application or independent subtree.
+
+## Select a colour mode
+
+Set `data-color-mode="light"` or `data-color-mode="dark"` on the theme root to force a mode. Omit
+the attribute to follow `prefers-color-scheme`.
+
+Colour-mode scopes are nestable. A nested scope can force the opposite mode without changing theme
+identity.
+
+
+
+Every explicit scope sets the native `color-scheme` property. Browser controls and scrollbars
+therefore use the same light or dark mode as Luke UI.
+
+## Portals
+
+Portalled content leaves its DOM ancestry, so copy the active identity class and explicit
+`data-color-mode` value to the portal root. When the source follows the system preference, omit the
+attribute on the portal root as well. Luke UI's Combobox copies these values automatically.
+Application-owned portals must preserve the same public class-and-attribute contract.
+
+## Server rendering and the first paint
+
+Render the identity classes and any known explicit colour mode in the server HTML. When the mode is
+system-controlled, omit `data-color-mode` so CSS can choose the correct mode before JavaScript runs.
+
+If a stored client preference can differ from the server default, run a small blocking bootstrap
+script before the themed UI is painted. It should read the stored preference and set the root class
+or `data-color-mode` before hydration. A React effect runs too late and paints the wrong mode first.
+Theme libraries such as `next-themes` can provide this bootstrap when configured to write the same
+attribute contract. Keep the server fallback and hydrated value consistent to avoid hydration
+warnings.
diff --git a/apps/docs/content/docs/theming/authoring.mdx b/apps/docs/content/docs/theming/authoring.mdx
new file mode 100644
index 00000000..a6e84a6f
--- /dev/null
+++ b/apps/docs/content/docs/theming/authoring.mdx
@@ -0,0 +1,55 @@
+---
+title: Authoring a theme
+description: Compile a typed Luke UI foundation into a static theme stylesheet.
+---
+
+`buildTheme` compiles a typed foundation into the complete semantic token contract. The function is
+pure, runs in Node, and produces static CSS containing the identity class and both colour modes.
+
+## Foundation fields
+
+A `ThemeFoundation` requires:
+
+- a kebab-case `name`
+- independent `light` and `dark` mode foundations
+- `color.neutral` and `color.accent` source colours for each mode
+- final `actionControlFinish.recessed`, `resting`, and `raised` background images for each mode
+- final `depth.recessed`, `resting`, `raised`, `floating`, and `overlay` box shadows for each mode
+
+Each mode may also provide `info`, `success`, `warning`, `danger`, and `focus` source colours. Luke
+UI uses accessible mode-specific defaults when they are omitted. Shared optional fields select one
+of the supported Capsize-compatible fonts and its four weight roles, plus the `detail`, `control`,
+`surface`, and `overlay` radii. Applications must load files for non-system fonts.
+
+
+
+
+
+## Compile the stylesheet
+
+Define a `ThemeFoundation` in the application, then run the public `buildTheme` API in a build
+script that writes its returned CSS to an application-owned stylesheet. Import that stylesheet
+through the application's bundler or framework. A custom theme does not need to be added to
+`@luke-ui/react` or wait for a Luke UI release. The two theme stylesheets included with Luke UI are
+defaults, not a registry of supported themes.
+
+
+
+Apply `themeRootClassName` with `themeClassName('product')`. The framework-neutral example below
+accepts its generated stylesheet URL explicitly. Select colour mode with the same `data-color-mode`
+contract used by bundled themes.
+
+
+
+## Contrast validation
+
+Generation validates the sRGB-gamut-mapped result against WCAG 2.2 AA. Text pairs must reach 4.5:1.
+Non-text UI pairs must reach 3:1. When a generated pair fails, `buildTheme` throws a
+`ThemeContrastError`. Its message and `failures` array name every failing colour mode and token
+pair, so the build should stop rather than publish an invalid stylesheet.
diff --git a/apps/docs/content/docs/theming/index.mdx b/apps/docs/content/docs/theming/index.mdx
new file mode 100644
index 00000000..0930238e
--- /dev/null
+++ b/apps/docs/content/docs/theming/index.mdx
@@ -0,0 +1,31 @@
+---
+title: Theming overview
+description: Choose a Luke UI theme identity and colour mode independently.
+---
+
+Luke UI separates **theme identity** from **colour mode**. Theme identity chooses the visual
+language. Colour mode chooses light, dark, or the system preference. The two settings are
+independent, so either bundled theme works in either mode.
+
+Luke UI includes two theme identities:
+
+- **Machined edge** is the default, compact tactile theme.
+- **ELMO** is a flatter, lower-depth alternative.
+
+A theme defines the semantic colours, typography, radius, material, and depth foundations shared by
+components. Luke UI defines component structure, states, and behaviour. Custom selectors, internal
+palettes, theme-defined component variants, and component slots are not part of the supported
+theming contract. Use public semantic CSS variables for local styling.
+
+Use the controls in the documentation header to preview any combination. Each choice is stored
+separately and applies to hosted examples and the playground. Storybook provides separate theme
+identity and colour-mode controls in its toolbar.
+
+Colour mode is nestable. A page can use one mode while a subtree forces the opposite mode. Apply one
+theme identity at each independent themed root. Nested theme identities are not supported.
+
+## Next steps
+
+- [Apply a bundled theme](/docs/theming/applying).
+- [Author a theme](/docs/theming/authoring).
+- [Browse the semantic token reference](/docs/theming/token-reference).
diff --git a/apps/docs/content/docs/theming/meta.json b/apps/docs/content/docs/theming/meta.json
new file mode 100644
index 00000000..469ed4fb
--- /dev/null
+++ b/apps/docs/content/docs/theming/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Theming",
+ "pages": ["index", "applying", "authoring", "token-reference"]
+}
diff --git a/apps/docs/content/docs/theming/token-reference.mdx b/apps/docs/content/docs/theming/token-reference.mdx
new file mode 100644
index 00000000..b164acb1
--- /dev/null
+++ b/apps/docs/content/docs/theming/token-reference.mdx
@@ -0,0 +1,28 @@
+---
+title: Token reference
+description: Generated reference for Luke UI's public semantic CSS variables.
+---
+
+`vars` is the typed public semantic-token contract. Each typed path maps to one stable global CSS
+custom property. Its segments become kebab case under the `--luke-` prefix. For example,
+`vars.color.intent.danger.surface.solidHover` resolves to
+`var(--luke-color-intent-danger-surface-solid-hover)`.
+
+The table below is generated from the public TypeScript declaration and its JSDoc rather than a
+separate handwritten token list.
+
+
+
+## Custom styling
+
+Use a public semantic variable when a component API or layout utility is not the right fit. This is
+the supported custom-styling boundary. Do not depend on private generated palette steps.
+
+
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 29bd4376..6bed60e5 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -13,8 +13,9 @@
"fix:format": "vp fmt . --write",
"fix:lint": "vp lint . --type-aware --fix",
"fix:unsafe": "vp lint . --type-aware --fix-dangerously",
- "generate": "pnpm run routes:generate && pnpm run generate:playground",
+ "generate": "pnpm run routes:generate && pnpm run generate:token-reference && pnpm run generate:playground",
"generate:playground": "node scripts/generate-playground-scope.ts && node scripts/generate-playground-types.ts && vp pack",
+ "generate:token-reference": "node scripts/generate-token-reference.ts",
"postinstall": "fumadocs-mdx",
"preview": "vp preview",
"routes:generate": "tsr generate",
diff --git a/apps/docs/scripts/generate-token-reference.ts b/apps/docs/scripts/generate-token-reference.ts
new file mode 100644
index 00000000..00a9dd36
--- /dev/null
+++ b/apps/docs/scripts/generate-token-reference.ts
@@ -0,0 +1,57 @@
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import ts from 'typescript';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const contractPath = resolve(scriptDir, '../../../packages/@luke-ui/react/src/theme/contract.ts');
+const outputPath = resolve(scriptDir, '../src/generated/token-reference.generated.ts');
+const { flattenThemeContract } = (await import(contractPath)) as {
+ flattenThemeContract: () => Array<[path: string, variable: string]>;
+};
+
+export function generateTokenReference(): string {
+ const descriptions = readFamilyDescriptions(readFileSync(contractPath, 'utf8'));
+ const entries = flattenThemeContract().map(([path, variable]) => {
+ const family = path.split('.')[0];
+ const description = family === undefined ? undefined : descriptions.get(family);
+ if (description === undefined) throw new Error(`Missing JSDoc for theme family "${family}"`);
+ return `\t/**\n\t * ${description}\n\t * CSS variable: \`${variable}\`.\n\t */\n\t'${path}': 'var(${variable})';`;
+ });
+
+ return `// Generated by scripts/generate-token-reference.ts — do not edit.\n\nexport interface ThemeTokenReference {\n${entries.join('\n')}\n}\n`;
+}
+
+function readFamilyDescriptions(source: string): Map {
+ const sourceFile = ts.createSourceFile(contractPath, source, ts.ScriptTarget.Latest, true);
+ const declaration = sourceFile.statements.find(
+ (statement): statement is ts.VariableStatement =>
+ ts.isVariableStatement(statement) &&
+ statement.declarationList.declarations.some(
+ (item) => ts.isIdentifier(item.name) && item.name.text === 'themeContractTree',
+ ),
+ );
+ const initializer = declaration?.declarationList.declarations.find(
+ (item) => ts.isIdentifier(item.name) && item.name.text === 'themeContractTree',
+ )?.initializer;
+ if (!initializer || !ts.isObjectLiteralExpression(initializer)) {
+ throw new Error('Could not find the themeContractTree object literal');
+ }
+
+ const descriptions = new Map();
+ for (const property of initializer.properties) {
+ if (!ts.isPropertyAssignment(property)) continue;
+ const name = property.name.getText(sourceFile).replaceAll("'", '');
+ const jsDoc = ts.getJSDocCommentsAndTags(property).find(ts.isJSDoc);
+ if (typeof jsDoc?.comment === 'string') descriptions.set(name, jsDoc.comment);
+ }
+ return descriptions;
+}
+
+if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const output = generateTokenReference();
+ mkdirSync(dirname(outputPath), { recursive: true });
+ writeFileSync(outputPath, output);
+ // oxlint-disable-next-line no-console
+ console.log(`generate-token-reference: wrote ${flattenThemeContract().length} token entries`);
+}
diff --git a/apps/docs/src/components/playground/color-mode-toggle.tsx b/apps/docs/src/components/playground/color-mode-toggle.tsx
new file mode 100644
index 00000000..efc4c059
--- /dev/null
+++ b/apps/docs/src/components/playground/color-mode-toggle.tsx
@@ -0,0 +1,70 @@
+import { MonitorIcon, MoonIcon, SunIcon } from 'lucide-react';
+import { useTheme } from 'next-themes';
+import { useSyncExternalStore } from 'react';
+import { IconToggleButtonGroup } from './icon-toggle-button-group.js';
+
+const COLOR_MODES = [
+ { Icon: SunIcon, label: 'Light theme', value: 'light' },
+ { Icon: MoonIcon, label: 'Dark theme', value: 'dark' },
+ { Icon: MonitorIcon, label: 'System theme', value: 'system' },
+] as const;
+
+type ColorMode = (typeof COLOR_MODES)[number]['value'];
+
+export function ColorModeToggle() {
+ const { setTheme } = useTheme();
+ const colorMode = useHydratedColorModeSelection();
+
+ return (
+
+ );
+}
+
+export function useHydratedColorMode(): Exclude | null {
+ const { resolvedTheme } = useTheme();
+ const isMounted = useIsMounted();
+
+ return isMounted && isColorMode(resolvedTheme) ? resolvedTheme : null;
+}
+
+export function useHydratedColorModeSelection(): ColorMode | null {
+ const { theme } = useTheme();
+ const isMounted = useIsMounted();
+
+ return isMounted && isColorModeSelection(theme) ? theme : null;
+}
+
+function useIsMounted() {
+ const isMounted = useSyncExternalStore(
+ subscribeToHydration,
+ getHydratedSnapshot,
+ getServerSnapshot,
+ );
+
+ return isMounted;
+}
+
+function isColorModeSelection(value: string | undefined): value is ColorMode {
+ return value === 'light' || value === 'dark' || value === 'system';
+}
+
+function isColorMode(value: string | undefined): value is Exclude {
+ return value === 'light' || value === 'dark';
+}
+
+function subscribeToHydration() {
+ return () => {};
+}
+
+function getHydratedSnapshot() {
+ return true;
+}
+
+function getServerSnapshot() {
+ return false;
+}
diff --git a/apps/docs/src/components/playground/preview-runner.browser.test.tsx b/apps/docs/src/components/playground/preview-runner.browser.test.tsx
new file mode 100644
index 00000000..ea9cf3ab
--- /dev/null
+++ b/apps/docs/src/components/playground/preview-runner.browser.test.tsx
@@ -0,0 +1,51 @@
+import '../../styles/app.css';
+import '@luke-ui/react/themes/elmo.css';
+import '@luke-ui/react/themes/machined-edge.css';
+import { elmoThemeClassName } from '@luke-ui/react/themes';
+import { ThemeProvider } from 'next-themes';
+import { act } from 'react';
+import type { Root } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
+import { afterEach, expect, test } from 'vite-plus/test';
+import { DocsThemeRoot } from '../theme-controls.js';
+import PreviewRunner from './preview-runner.js';
+
+let container: HTMLElement | undefined;
+let root: Root | undefined;
+
+afterEach(() => {
+ if (root) act(() => root?.unmount());
+ container?.remove();
+ localStorage.clear();
+ document.documentElement.removeAttribute('class');
+ document.documentElement.removeAttribute('data-color-mode');
+ container = undefined;
+ root = undefined;
+});
+
+test('applies appearance messages to the playground preview root', async () => {
+ container = document.body.appendChild(document.createElement('div'));
+ root = createRoot(container);
+ await act(async () => {
+ root?.render(
+
+
+
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ window.postMessage(
+ { colorMode: 'dark', themeIdentity: 'elmo', type: 'playground:appearance' },
+ window.location.origin,
+ );
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ });
+
+ const themeRoot = container.querySelector('[data-color-mode]');
+ if (!themeRoot) throw new Error('Expected a playground theme root');
+ await expect.poll(() => themeRoot.dataset.colorMode).toBe('dark');
+ expect(themeRoot).toHaveClass(elmoThemeClassName);
+});
diff --git a/apps/docs/src/components/playground/preview-runner.tsx b/apps/docs/src/components/playground/preview-runner.tsx
index 178b73cb..0a234e69 100644
--- a/apps/docs/src/components/playground/preview-runner.tsx
+++ b/apps/docs/src/components/playground/preview-runner.tsx
@@ -1,3 +1,4 @@
+import { useTheme } from 'next-themes';
import type { ComponentType } from 'react';
import { useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
@@ -5,8 +6,9 @@ import { transform } from 'sucrase';
import { playgroundScope } from '../../generated/playground-scope.generated';
import { decodeCodeHash } from '../../lib/playground-hash';
import type { PlaygroundPreviewMessage } from '../../lib/playground-protocol';
-import { isPlaygroundCodeMessage } from '../../lib/playground-protocol';
+import { isPlaygroundParentMessage } from '../../lib/playground-protocol';
import { StoryWrapper } from '../../lib/story-wrapper';
+import { useDocsThemeIdentity } from '../theme-controls';
// Interop wrappers are cached so repeated requires return stable module objects.
const moduleCache = new Map>();
@@ -15,6 +17,8 @@ type PreviewRun = { UserComponent: ComponentType; runId: number };
export default function PreviewRunner() {
const [run, setRun] = useState(null);
+ const { setTheme: setColorMode } = useTheme();
+ const { setThemeIdentity } = useDocsThemeIdentity();
useEffect(() => {
let runId = 0;
@@ -32,7 +36,13 @@ export default function PreviewRunner() {
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
- if (isPlaygroundCodeMessage(event.data)) runCode(event.data.code);
+ if (!isPlaygroundParentMessage(event.data)) return;
+ if (event.data.type === 'playground:code') {
+ runCode(event.data.code);
+ return;
+ }
+ setThemeIdentity(event.data.themeIdentity);
+ setColorMode(event.data.colorMode);
};
window.addEventListener('message', onMessage);
@@ -40,7 +50,7 @@ export default function PreviewRunner() {
if (initialCode) runCode(initialCode);
postToParent({ type: 'playground:ready' });
return () => window.removeEventListener('message', onMessage);
- }, []);
+ }, [setColorMode, setThemeIdentity]);
if (!run) return null;
diff --git a/apps/docs/src/components/playground/theme-toggle.tsx b/apps/docs/src/components/playground/theme-toggle.tsx
deleted file mode 100644
index 225b9655..00000000
--- a/apps/docs/src/components/playground/theme-toggle.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { MoonIcon, SunIcon } from 'lucide-react';
-import { useTheme } from 'next-themes';
-import { useSyncExternalStore } from 'react';
-import { IconToggleButtonGroup } from './icon-toggle-button-group';
-
-const THEMES = [
- { Icon: SunIcon, label: 'Light theme', value: 'light' },
- { Icon: MoonIcon, label: 'Dark theme', value: 'dark' },
-] as const;
-
-type Theme = (typeof THEMES)[number]['value'];
-
-export function ThemeToggle() {
- const { setTheme } = useTheme();
- const theme = useHydratedTheme();
-
- return ;
-}
-
-export function useHydratedTheme(): Theme | null {
- const { resolvedTheme } = useTheme();
- const isMounted = useSyncExternalStore(
- subscribeToHydration,
- getHydratedSnapshot,
- getServerSnapshot,
- );
-
- return isMounted && isTheme(resolvedTheme) ? resolvedTheme : null;
-}
-
-function isTheme(value: string | undefined): value is Theme {
- return value === 'light' || value === 'dark';
-}
-
-function subscribeToHydration() {
- return () => {};
-}
-
-function getHydratedSnapshot() {
- return true;
-}
-
-function getServerSnapshot() {
- return false;
-}
diff --git a/apps/docs/src/components/source-code-block.tsx b/apps/docs/src/components/source-code-block.tsx
new file mode 100644
index 00000000..c30986b5
--- /dev/null
+++ b/apps/docs/src/components/source-code-block.tsx
@@ -0,0 +1,18 @@
+import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
+
+const sources = import.meta.glob('../samples/*/*.tsx', {
+ eager: true,
+ import: 'default',
+ query: '?raw',
+});
+
+export interface SourceCodeBlockProps {
+ src: string;
+}
+
+export function SourceCodeBlock({ src }: SourceCodeBlockProps) {
+ const source = sources[`../samples/${src}.tsx`];
+ if (source === undefined) throw new Error(`Source example not found: ${src}`);
+
+ return ;
+}
diff --git a/apps/docs/src/components/theme-controls.browser.test.tsx b/apps/docs/src/components/theme-controls.browser.test.tsx
index a5ff151c..5352a5a8 100644
--- a/apps/docs/src/components/theme-controls.browser.test.tsx
+++ b/apps/docs/src/components/theme-controls.browser.test.tsx
@@ -1,42 +1,79 @@
import '../styles/app.css';
import '@luke-ui/react/themes/elmo.css';
import '@luke-ui/react/themes/machined-edge.css';
+import { elmoThemeClassName } from '@luke-ui/react/themes';
import { ThemeProvider } from 'next-themes';
import { act } from 'react';
-import type { ReactNode } from 'react';
+import type { ComponentProps, ReactNode } from 'react';
import type { Root } from 'react-dom/client';
-import { createRoot, hydrateRoot } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
-import { afterEach, expect, test, vi } from 'vite-plus/test';
-import { page, userEvent } from 'vite-plus/test/context';
+import { afterEach, expect, test } from 'vite-plus/test';
+import { cdp, page, userEvent } from 'vite-plus/test/context';
import { StoryWrapper } from '../lib/story-wrapper';
import { DocsThemeRoot, ThemeControls } from './theme-controls';
let container: HTMLElement | undefined;
let root: Root | undefined;
-afterEach(() => {
+afterEach(async () => {
if (root) act(() => root?.unmount());
container?.remove();
localStorage.clear();
document.documentElement.removeAttribute('class');
container = undefined;
root = undefined;
+ await emulateColorScheme('light');
});
-test('exposes the playground colour-mode toggle beside the theme profile control', async () => {
- renderTheme();
+test('persists theme identity and colour mode independently', async () => {
+ renderTheme(
+ <>
+
+
+ Theme example
+
+ >,
+ );
const profile = page.getByRole('combobox', { name: 'Theme profile' });
- const darkMode = getDarkModeButton();
+ const darkMode = page.getByRole('radio', { name: 'Dark theme' });
+ const themeRoot = getThemeRoot();
await userEvent.selectOptions(profile, 'elmo');
- await userEvent.click(darkMode, { force: true });
expect(profile).toHaveValue('elmo');
+ expect(themeRoot).toHaveClass(elmoThemeClassName);
+ expect(themeRoot.dataset.colorMode).toBe('light');
+
+ await userEvent.click(darkMode, { force: true });
+
+ await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
+ expect(themeRoot).toHaveClass(elmoThemeClassName);
+
+ unmountTheme();
+ renderTheme();
+
+ expect(page.getByRole('combobox', { name: 'Theme profile' })).toHaveValue('elmo');
+ expect(page.getByRole('radio', { name: 'Dark theme' })).toBeChecked();
+ expect(getThemeRoot()).toHaveClass(elmoThemeClassName);
await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
});
+test('system colour mode follows the platform preference and drives the docs chrome', async () => {
+ await emulateColorScheme('dark');
+ renderTheme(, { defaultTheme: 'system', enableSystem: true });
+
+ await userEvent.click(page.getByRole('radio', { name: 'System theme' }), { force: true });
+
+ await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
+ expect(document.documentElement).toHaveClass('dark');
+
+ await emulateColorScheme('light');
+ await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('light');
+ expect(document.documentElement).toHaveClass('light');
+});
+
test('bridges dark mode into the Luke UI root and example canvas', async () => {
renderTheme(
<>
@@ -53,7 +90,7 @@ test('bridges dark mode into the Luke UI root and example canvas', async () => {
if (!exampleCanvas) throw new Error('Expected an example canvas');
const lightBackground = getComputedStyle(exampleCanvas).backgroundColor;
- await userEvent.click(getDarkModeButton(), { force: true });
+ await userEvent.click(page.getByRole('radio', { name: 'Dark theme' }), { force: true });
await expect.poll(() => themeRoot.dataset.colorMode).toBe('dark');
expect(getComputedStyle(exampleCanvas).backgroundColor).not.toBe(lightBackground);
@@ -68,7 +105,7 @@ test('keeps inherited docs shell text readable in dark mode', async () => {
>,
);
- await userEvent.click(getDarkModeButton(), { force: true });
+ await userEvent.click(page.getByRole('radio', { name: 'Dark theme' }), { force: true });
await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
const shellText = page.getByText('Unstyled shell text').element();
@@ -78,48 +115,53 @@ test('keeps inherited docs shell text readable in dark mode', async () => {
expect(getComputedStyle(shellText).color).toBe(getComputedStyle(foregroundProbe).color);
});
-test('omits the colour mode until hydration completes', async () => {
+test('boots the stored colour mode before the themed root hydrates', async () => {
localStorage.setItem('theme', 'dark');
- const tree = (
-
-
- Hydrated content
-
-
+ const bootstrap = renderToString(
+
+ Server content
+ ,
+ );
+ const iframe = document.body.appendChild(document.createElement('iframe'));
+ iframe.srcdoc = `${bootstrap}`;
+ await new Promise((resolve) =>
+ iframe.addEventListener('load', () => resolve(), { once: true }),
);
- const serverMarkup = renderToString(tree);
- expect(serverMarkup).not.toContain('data-color-mode');
-
- container = document.body.appendChild(document.createElement('div'));
- container.innerHTML = serverMarkup;
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
- await act(async () => {
- root = hydrateRoot(container as HTMLElement, tree);
- });
- await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
- expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('hydration mismatch'));
- consoleError.mockRestore();
+ expect(iframe.contentDocument?.documentElement).toHaveAttribute('data-color-mode', 'dark');
+ expect(iframe.contentDocument?.documentElement).toHaveAttribute('data-mode-at-hydration', 'dark');
+ iframe.remove();
});
-function renderTheme(children: ReactNode) {
+function renderTheme(
+ children: ReactNode,
+ options: Pick, 'defaultTheme' | 'enableSystem'> = {},
+) {
container = document.body.appendChild(document.createElement('div'));
root = createRoot(container);
act(() => {
root?.render(
-
+ {children},
);
});
}
-function getDarkModeButton() {
- const button = container?.querySelector('button[aria-label="Dark theme"]');
- if (!button) throw new Error('Expected an accessible dark theme button');
-
- return page.elementLocator(button);
+function unmountTheme() {
+ act(() => root?.unmount());
+ container?.remove();
+ container = undefined;
+ root = undefined;
}
function getThemeRoot() {
@@ -128,3 +170,9 @@ function getThemeRoot() {
return themeRoot;
}
+
+async function emulateColorScheme(mode: 'light' | 'dark') {
+ await cdp().send('Emulation.setEmulatedMedia', {
+ features: [{ name: 'prefers-color-scheme', value: mode }],
+ });
+}
diff --git a/apps/docs/src/components/theme-controls.tsx b/apps/docs/src/components/theme-controls.tsx
index 5117f7e5..36d2ed7a 100644
--- a/apps/docs/src/components/theme-controls.tsx
+++ b/apps/docs/src/components/theme-controls.tsx
@@ -2,46 +2,49 @@ import { themeRootClassName } from '@luke-ui/react/theme';
import { elmoThemeClassName, machinedEdgeThemeClassName } from '@luke-ui/react/themes';
import { cx } from '@luke-ui/react/utils';
import type { ChangeEvent, ComponentProps, PropsWithChildren } from 'react';
-import { createContext, useContext, useMemo, useState } from 'react';
-import { ThemeToggle, useHydratedTheme } from './playground/theme-toggle';
+import { createContext, useContext, useMemo, useSyncExternalStore } from 'react';
+import { ColorModeToggle, useHydratedColorMode } from './playground/color-mode-toggle.js';
-export type ThemeName = 'elmo' | 'machined-edge';
+export type ThemeIdentity = 'elmo' | 'machined-edge';
-interface ThemeSettings {
- setTheme: (theme: ThemeName) => void;
- theme: ThemeName;
+const THEME_IDENTITY_STORAGE_KEY = 'luke-ui-docs-theme';
+const THEME_IDENTITY_CHANGE_EVENT = 'luke-ui-docs-theme-change';
+
+interface ThemeIdentitySettings {
+ setThemeIdentity: (themeIdentity: ThemeIdentity) => void;
+ themeIdentity: ThemeIdentity;
}
-const ThemeSettingsContext = createContext(null);
+const ThemeIdentitySettingsContext = createContext(null);
export function DocsThemeRoot({ children }: PropsWithChildren) {
- const colorMode = useHydratedTheme();
- const [theme, setTheme] = useState('machined-edge');
- const themeClassName =
- theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName;
- const settings = useMemo(() => ({ setTheme, theme }), [theme]);
+ const colorMode = useHydratedColorMode();
+ const themeIdentity = useThemeIdentity();
+ const themeIdentityClassName =
+ themeIdentity === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName;
+ const settings = useMemo(() => ({ setThemeIdentity, themeIdentity }), [themeIdentity]);
return (
-
+