diff --git a/apps/docs/content/docs/applying-a-theme.mdx b/apps/docs/content/docs/applying-a-theme.mdx
index a7849e42..abff1065 100644
--- a/apps/docs/content/docs/applying-a-theme.mdx
+++ b/apps/docs/content/docs/applying-a-theme.mdx
@@ -1,28 +1,26 @@
---
title: Applying a theme
-description: Load Luke UI CSS and apply a theme identity and colour mode.
+description: Load Luke UI CSS and apply the shared root class.
---
-A themed subtree needs the shared Luke UI stylesheet, one identity stylesheet, and a root that
-carries both theme classes. All three are static. Luke UI does not inject styles at runtime.
+A themed subtree needs the shared Luke UI stylesheet and one theme stylesheet. Both are static CSS.
+Luke UI does not inject styles at runtime.
## Import the stylesheets
-Import `@luke-ui/react/stylesheet.css` and the stylesheet for the identity your application uses.
-The example below uses Tactile. Use `@luke-ui/react/themes/paper.css` with `paperThemeClassName` for
-Paper. If you import one bundled identity, Luke UI does not load the other.
+Import `@luke-ui/react/stylesheet.css` and one theme's stylesheet, for example
+`@luke-ui/react/themes/tactile/stylesheet.css`. A theme stylesheet themes the whole document from
+`:root`, with no class and no JavaScript. Import one bundled theme, and Luke UI does not load the
+other.
-## Apply the root classes
+## Apply the root class
-Apply `themeRootClassName` and the matching identity class to the same element near the application
-root. `themeRootClassName` includes the reset and base theme class. The identity class supplies the
-semantic values.
+Apply `rootClassName` to an element you own, such as the application shell. It supplies the reset
+and base typography. It carries no theme identity, so it works the same way for a bundled theme and
+a custom theme.
-Theme identities do not nest. Create a separate theme root for an independent subtree that needs
-another identity.
-
## Choose a colour mode
Leave `data-color-mode` unset to follow `prefers-color-scheme`. Set `data-color-mode="light"` or
@@ -33,9 +31,8 @@ match the Luke UI content.
## Nest colour-mode scopes
-Colour modes nest within one theme identity. Use a nested mode when one part of the interface must
-remain light or dark independently of its parent. Do not add a second identity class to the nested
-element.
+Colour modes nest freely. Use a nested mode when one part of the interface must stay light or dark
+independently of its parent.
`, or to the root of the subtree that
+needs it.
+
+An authored theme gets its class the same way, from `getThemeClassName` in `@luke-ui/react/theme`.
+Pass the `name` your `ThemeInput` declares. It returns the same class the generated stylesheet
+selects on, and throws when the name is not kebab-case.
-## Preserve the theme in portals
+
-Application-owned portals render outside the themed DOM branch, so inherited theme variables no
-longer reach them. Apply `themeRootClassName`, the active identity class, and the nearest explicit
-`data-color-mode` value to the portal root. Leave the attribute off when the source follows the
-system setting.
+Theme identities do not nest. Never place one identity class inside another identity's subtree,
+because the two resolve at equal precedence and stylesheet order decides the winner.
## Next steps
diff --git a/apps/docs/content/docs/authoring-a-theme.mdx b/apps/docs/content/docs/authoring-a-theme.mdx
index e046c3f8..cf7bd88f 100644
--- a/apps/docs/content/docs/authoring-a-theme.mdx
+++ b/apps/docs/content/docs/authoring-a-theme.mdx
@@ -3,9 +3,9 @@ title: Authoring a theme
description: Compile a curated theme input into a static Luke UI theme stylesheet.
---
-Author a custom theme when the bundled identities do not suit the product. `defineTheme` turns a
-small, curated `ThemeInput` into static CSS for one identity with light and dark modes. It does not
-need a browser or a Luke UI release.
+Author a custom theme when the bundled themes do not suit the product. `defineTheme` turns a small,
+curated `ThemeInput` into static CSS for one theme with light and dark modes. It does not need a
+browser or a Luke UI release.
## Define the input
@@ -35,6 +35,10 @@ The minimal theme authors one colour and a neutral character:
+Start from a bundled theme instead of a blank input. Each bundled theme's entrypoint, for example
+`@luke-ui/react/themes/tactile`, exports `theme`, its own `ThemeInput`. Read it, copy it, or spread
+it into your own `defineTheme` call.
+
## Generate a stylesheet
Call `defineTheme` from an application build script. Write the returned CSS to a stylesheet your
@@ -42,11 +46,19 @@ framework can load.
-Apply `themeRootClassName` and `themeClassName('product')` to the same root. The supplied name must
-match the input's `name`. Colour mode uses the same `data-color-mode` attribute as bundled themes.
+Apply a product theme exactly like a bundled one. Load the generated stylesheet. Apply
+`rootClassName` to an element you own.
+
+The product theme's own stylesheet themes the document. Colour mode uses the same `data-color-mode`
+attribute as bundled themes.
+The theme needs no identity class unless the same document also loads another theme. When it does,
+call `getThemeClassName` from `@luke-ui/react/theme` with the `name` your `ThemeInput` declares.
+That is the same helper the bundled themes use for their own classes. See
+[Applying a theme](/applying-a-theme) for where to put the result.
+
Applications must load any non-system font files their `typography` selects.
## Contrast validation
diff --git a/apps/docs/content/docs/installation.mdx b/apps/docs/content/docs/installation.mdx
index 7f9024e6..125db870 100644
--- a/apps/docs/content/docs/installation.mdx
+++ b/apps/docs/content/docs/installation.mdx
@@ -18,13 +18,13 @@ pnpm add @luke-ui/react
### 2. Import the CSS
-Import the shared stylesheet and the stylesheet for one identity at your application root. This
-example uses Tactile. To use Paper, import `@luke-ui/react/themes/paper.css` instead.
+Import the shared stylesheet and one theme's stylesheet at your application root. This example uses
+Tactile. To use Paper, import `@luke-ui/react/themes/paper/stylesheet.css` instead.
### 3. Add the theme root
-Apply `themeRootClassName` and the matching identity class to the same element. The theme root
-provides the reset, base typography, and semantic variables for its children.
+Apply `rootClassName` to an element you own, such as the application shell. The theme stylesheet
+supplies the semantic variables. `rootClassName` adds the reset and base typography.
@@ -37,8 +37,8 @@ Import each component from its package path. The setup above renders `Text`. `Bu
## Change the theme
-Tactile is the default bundled identity. Paper uses the same component APIs with a flatter visual
-treatment. Switch the CSS import and class together.
+Tactile is the default bundled theme. Paper uses the same component APIs with a flatter visual
+treatment. Switch which theme stylesheet you import to change the theme.
diff --git a/apps/docs/content/docs/styling.mdx b/apps/docs/content/docs/styling.mdx
index 43428629..7e0ce124 100644
--- a/apps/docs/content/docs/styling.mdx
+++ b/apps/docs/content/docs/styling.mdx
@@ -68,8 +68,8 @@ from a curated accent and neutral character. It is not a per-component override
## Set up static styles
Import `@luke-ui/react/stylesheet.css` and one bundled theme stylesheet at the application entry
-point. Apply `themeRootClassName` and the matching identity class to the same application or subtree
-root. Luke UI scopes the reset to that root, so it does not reset unrelated application content.
+point. Apply `rootClassName` to the application or subtree root you own. Luke UI scopes the reset to
+that root, so it does not reset unrelated application content.
@@ -79,7 +79,7 @@ Read [Applying a theme](/applying-a-theme) for colour modes, portals, and other
Luke UI's static CSS uses four cascade layers, ordered from lowest to highest priority: `reset`,
`theme`, `recipes`, and `utilities`. That internal order is stable regardless of stylesheet import
-order. The reset normalises only the subtree that carries `themeRootClassName`.
+order. The reset normalises only the subtree that carries `rootClassName`.
If your application also uses cascade layers, declare its layer order before you import stylesheets.
diff --git a/apps/docs/content/docs/theming.mdx b/apps/docs/content/docs/theming.mdx
index ede3a108..bb06a55d 100644
--- a/apps/docs/content/docs/theming.mdx
+++ b/apps/docs/content/docs/theming.mdx
@@ -8,16 +8,18 @@ does not change component behaviour.
## Theme foundations
-Every themed subtree has one root element. It carries `themeRootClassName` and one identity class.
+A theme stylesheet themes the whole document from `:root`. Import one theme stylesheet, and every
+element uses its semantic values, with no class applied and no JavaScript.
-`themeRootClassName` applies the shared reset and base theme layer. The identity class supplies the
-semantic token values used by components and custom UI.
+`rootClassName` applies the shared reset and base typography. It carries no theme identity. Apply it
+to an element you already own, such as the application shell.
-Each identity includes light and dark colour values. The active colour mode chooses between them. An
+Each theme includes light and dark colour values. The active colour mode chooses between them. An
explicit `data-color-mode` is optional. Without one, the theme follows the system preference.
-Colour-mode scopes can nest within an identity. Identity classes cannot.
+Colour-mode scopes nest freely. Theme identities do not.
-Use a separate root for an independent subtree that needs another identity.
+Loading more than one theme in the same document needs an explicit identity class, so one theme wins
+over the other's `:root` fallback.
Read [Applying a theme](/applying-a-theme) for the exact imports, classes, and colour-mode rules.
diff --git a/apps/docs/src/components/color-mode-override.browser.test.tsx b/apps/docs/src/components/color-mode-override.browser.test.tsx
index c6c41099..27f6789c 100644
--- a/apps/docs/src/components/color-mode-override.browser.test.tsx
+++ b/apps/docs/src/components/color-mode-override.browser.test.tsx
@@ -1,6 +1,6 @@
import '../styles/app.css';
-import '@luke-ui/react/themes/tactile.css';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
diff --git a/apps/docs/src/components/example-block.browser.test.tsx b/apps/docs/src/components/example-block.browser.test.tsx
index 0a7af271..7feb192e 100644
--- a/apps/docs/src/components/example-block.browser.test.tsx
+++ b/apps/docs/src/components/example-block.browser.test.tsx
@@ -1,8 +1,8 @@
import '../styles/app.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { IconSpritesheetProvider } from '@luke-ui/react/icon';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
diff --git a/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx b/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx
index 2344680e..0e6e1308 100644
--- a/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx
+++ b/apps/docs/src/components/playground/editor-skeleton.browser.test.tsx
@@ -1,7 +1,8 @@
import '../../styles/app.css';
-import '@luke-ui/react/themes/paper.css';
-import '@luke-ui/react/themes/tactile.css';
-import { paperThemeClassName, tactileThemeClassName } from '@luke-ui/react/themes';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { themeClassName as paperThemeClassName } from '@luke-ui/react/themes/paper';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
diff --git a/apps/docs/src/components/playground/preview-runner.browser.test.tsx b/apps/docs/src/components/playground/preview-runner.browser.test.tsx
index 0f8ce5b5..b770d276 100644
--- a/apps/docs/src/components/playground/preview-runner.browser.test.tsx
+++ b/apps/docs/src/components/playground/preview-runner.browser.test.tsx
@@ -1,7 +1,7 @@
import '../../styles/app.css';
-import '@luke-ui/react/themes/paper.css';
-import '@luke-ui/react/themes/tactile.css';
-import { paperThemeClassName } from '@luke-ui/react/themes';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { themeClassName as paperThemeClassName } from '@luke-ui/react/themes/paper';
import { ThemeProvider } from 'next-themes';
import { act } from 'react';
import type { Root } from 'react-dom/client';
@@ -47,5 +47,5 @@ test('applies appearance messages to the playground preview root', async () => {
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(paperThemeClassName);
+ expect(document.documentElement).toHaveClass(paperThemeClassName);
});
diff --git a/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx b/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx
index 538d922f..8004ce33 100644
--- a/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx
+++ b/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx
@@ -1,6 +1,6 @@
import '../../styles/app.css';
-import '@luke-ui/react/themes/paper.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { act, useState } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
diff --git a/apps/docs/src/components/site-nav.browser.test.tsx b/apps/docs/src/components/site-nav.browser.test.tsx
index ba555d91..85abcc3a 100644
--- a/apps/docs/src/components/site-nav.browser.test.tsx
+++ b/apps/docs/src/components/site-nav.browser.test.tsx
@@ -1,5 +1,5 @@
import '../styles/app.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { IconSpritesheetProvider } from '@luke-ui/react/icon';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
import {
diff --git a/apps/docs/src/components/theme-controls.browser.test.tsx b/apps/docs/src/components/theme-controls.browser.test.tsx
index 17d46dd3..15503e0b 100644
--- a/apps/docs/src/components/theme-controls.browser.test.tsx
+++ b/apps/docs/src/components/theme-controls.browser.test.tsx
@@ -1,9 +1,9 @@
import '../styles/app.css';
-import '@luke-ui/react/themes/paper.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { IconSpritesheetProvider } from '@luke-ui/react/icon';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
-import { paperThemeClassName } from '@luke-ui/react/themes';
+import { themeClassName as paperThemeClassName } from '@luke-ui/react/themes/paper';
import { ThemeProvider } from 'next-themes';
import { act } from 'react';
import type { ComponentProps, ReactNode } from 'react';
@@ -45,20 +45,20 @@ test('persists theme identity and colour mode independently', async () => {
await userEvent.click(paperProfile, { force: true });
expect(paperProfile).toBeChecked();
- expect(themeRoot).toHaveClass(paperThemeClassName);
+ expect(document.documentElement).toHaveClass(paperThemeClassName);
expect(themeRoot.dataset.colorMode).toBe('light');
await userEvent.click(darkMode, { force: true });
await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
- expect(themeRoot).toHaveClass(paperThemeClassName);
+ expect(document.documentElement).toHaveClass(paperThemeClassName);
unmountTheme();
renderTheme();
expect(page.getByRole('radio', { name: 'Paper' })).toBeChecked();
expect(page.getByRole('radio', { name: 'Dark theme' })).toBeChecked();
- expect(getThemeRoot()).toHaveClass(paperThemeClassName);
+ expect(document.documentElement).toHaveClass(paperThemeClassName);
await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark');
});
@@ -81,7 +81,7 @@ test('theme profile is labelled and operable with arrow keys', async () => {
await userEvent.keyboard('{Enter}');
expect(paperProfile).toBeChecked();
- expect(getThemeRoot()).toHaveClass(paperThemeClassName);
+ expect(document.documentElement).toHaveClass(paperThemeClassName);
});
test('system colour mode follows the platform preference and drives the docs chrome', async () => {
diff --git a/apps/docs/src/components/theme-controls.tsx b/apps/docs/src/components/theme-controls.tsx
index c1136f35..d68acc9b 100644
--- a/apps/docs/src/components/theme-controls.tsx
+++ b/apps/docs/src/components/theme-controls.tsx
@@ -1,8 +1,15 @@
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { paperThemeClassName, tactileThemeClassName } from '@luke-ui/react/themes';
+import { rootClassName } from '@luke-ui/react/theme';
+import { themeClassName as paperThemeClassName } from '@luke-ui/react/themes/paper';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
import { cx } from '@luke-ui/react/utils';
import type { ComponentProps, PropsWithChildren } from 'react';
-import { createContext, useContext, useMemo, useSyncExternalStore } from 'react';
+import {
+ createContext,
+ useContext,
+ useInsertionEffect,
+ useMemo,
+ useSyncExternalStore,
+} from 'react';
import { ColorModeToggle, useHydratedColorMode } from './playground/color-mode-toggle.js';
import { TextToggleButtonGroup } from './playground/icon-toggle-button-group.js';
@@ -11,6 +18,11 @@ export type ThemeIdentity = 'paper' | 'tactile';
const THEME_IDENTITY_STORAGE_KEY = 'luke-ui-docs-theme';
const THEME_IDENTITY_CHANGE_EVENT = 'luke-ui-docs-theme-change';
+const THEME_IDENTITY_CLASS_NAMES = {
+ paper: paperThemeClassName,
+ tactile: tactileThemeClassName,
+} as const satisfies Record;
+
const THEME_IDENTITIES = [
{ label: 'Tactile', value: 'tactile' },
{ label: 'Paper', value: 'paper' },
@@ -26,18 +38,22 @@ const ThemeIdentitySettingsContext = createContext
export function DocsThemeRoot({ children }: PropsWithChildren) {
const colorMode = useHydratedColorMode();
const themeIdentity = useThemeIdentity();
- const themeIdentityClassName =
- themeIdentity === 'tactile' ? tactileThemeClassName : paperThemeClassName;
const settings = useMemo(() => ({ setThemeIdentity, themeIdentity }), [themeIdentity]);
+ // The class goes on ``, not this root `div`, so a body-level portal inherits it too.
+ // `useInsertionEffect` applies it before the browser paints.
+ useInsertionEffect(() => {
+ const identityClassName = THEME_IDENTITY_CLASS_NAMES[themeIdentity];
+ document.documentElement.classList.add(identityClassName);
+ return () => {
+ document.documentElement.classList.remove(identityClassName);
+ };
+ }, [themeIdentity]);
+
return (
{children}
diff --git a/apps/docs/src/components/theme-dom-guard.browser.test.tsx b/apps/docs/src/components/theme-dom-guard.browser.test.tsx
index 691eddbf..8169759f 100644
--- a/apps/docs/src/components/theme-dom-guard.browser.test.tsx
+++ b/apps/docs/src/components/theme-dom-guard.browser.test.tsx
@@ -1,7 +1,7 @@
import '../styles/app.css';
-import '@luke-ui/react/themes/tactile.css';
-import { themeClassName, themeRootClassName } from '@luke-ui/react/theme';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { rootClassName } from '@luke-ui/react/theme';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
import { cx } from '@luke-ui/react/utils';
import { act } from 'react';
import type { ReactNode } from 'react';
@@ -17,6 +17,7 @@ let root: Root | undefined;
afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
+ document.documentElement.removeAttribute('class');
container = undefined;
root = undefined;
});
@@ -31,15 +32,20 @@ function renderInDocsThemeRoot(children: ReactNode) {
}
/**
- * Every identity class starts with this prefix, emitted by `themeClassName`. It is the
- * theme-layer marker for a supported identity boundary, so a nested identity is caught without
- * depending on any single bundled theme name.
+ * Every identity class starts with this prefix, emitted by the theme layer's internal
+ * `themeClassName` helper. It is the marker for a supported identity boundary, so a nested
+ * identity is caught without depending on any single bundled theme name or on that helper, which
+ * is not part of the public API.
*/
const IDENTITY_CLASS_PREFIX = 'luke-ui-theme-';
-function nestedIdentityElementsWithin(themeRoot: HTMLElement): Array {
- const nested: Array = [];
- for (const element of themeRoot.querySelectorAll('*')) {
+// A literal identity class rather than an import: the same shape `themeClassName('product')`
+// used to produce, kept as a string now that helper is internal to the theme package.
+const NESTED_IDENTITY_CLASS_NAME = 'luke-ui-theme-product';
+
+function nestedIdentityElementsWithin(themeRoot: Element): Array {
+ const nested: Array = [];
+ for (const element of themeRoot.querySelectorAll('*')) {
if (Array.from(element.classList).some((token) => token.startsWith(IDENTITY_CLASS_PREFIX))) {
nested.push(element);
}
@@ -51,10 +57,7 @@ test('detects a supported identity class nested beneath the docs identity root',
renderInDocsThemeRoot(
Docs themed example
-
+
Nested application root
,
@@ -65,7 +68,28 @@ test('detects a supported identity class nested beneath the docs identity root',
const nested = nestedIdentityElementsWithin(host);
expect(nested).toHaveLength(1);
- expect(nested[0]?.className).toContain(themeClassName('product'));
+ expect(nested[0]?.className).toContain(NESTED_IDENTITY_CLASS_NAME);
+});
+
+test('detects a nested identity when the docs identity sits on ', () => {
+ // Mirrors `DocsThemeRoot`'s topology: the docs identity now lives on ``, not on a `div`
+ // the docs own. An application root rendered anywhere in the document with its own identity
+ // class is nested beneath it, and is now structurally easier to hit.
+ document.documentElement.classList.add(tactileThemeClassName);
+
+ container = document.body.appendChild(document.createElement('div'));
+ root = createRoot(container);
+ act(() => {
+ root?.render(
+
+ Nested application root
+ ,
+ );
+ });
+
+ const nested = nestedIdentityElementsWithin(document.documentElement);
+ expect(nested).toHaveLength(1);
+ expect(nested[0]?.className).toContain(NESTED_IDENTITY_CLASS_NAME);
});
test('rendering the theming examples under the docs identity root adds no nested identity', () => {
diff --git a/apps/docs/src/components/token-explorer.browser.test.tsx b/apps/docs/src/components/token-explorer.browser.test.tsx
index 3626198a..39139fe6 100644
--- a/apps/docs/src/components/token-explorer.browser.test.tsx
+++ b/apps/docs/src/components/token-explorer.browser.test.tsx
@@ -1,5 +1,5 @@
import '../styles/app.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import {
createMemoryHistory,
createRootRoute,
diff --git a/apps/docs/src/lib/theming-docs.test.ts b/apps/docs/src/lib/theming-docs.test.ts
index 74ea67a1..68eafeb5 100644
--- a/apps/docs/src/lib/theming-docs.test.ts
+++ b/apps/docs/src/lib/theming-docs.test.ts
@@ -77,7 +77,17 @@ test('spacing, radius, and shadow live on the token reference', () => {
});
test('no rendered example applies a theme identity class', () => {
- const identityClassNames = ['tactileThemeClassName', 'paperThemeClassName', 'themeClassName'];
+ // `themeClassName` is the export name every per-theme entrypoint
+ // (`@luke-ui/react/themes/paper`, `@luke-ui/react/themes/tactile`) uses for its identity class,
+ // and `tactileThemeClassName`/`paperThemeClassName` are the aliases docs code imports it under.
+ // `getThemeClassName` derives one for an authored theme. A rendered example using any of them
+ // would establish its own identity and nest one inside the docs' own ``-level identity.
+ const identityClassNames = [
+ 'tactileThemeClassName',
+ 'paperThemeClassName',
+ 'themeClassName',
+ 'getThemeClassName',
+ ];
for (const file of findAllMdxFiles(contentDir)) {
const contents = readFileSync(file, 'utf8');
diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx
index 6edae1ba..08f66581 100644
--- a/apps/docs/src/routes/__root.tsx
+++ b/apps/docs/src/routes/__root.tsx
@@ -1,7 +1,7 @@
import { IconSpritesheetProvider } from '@luke-ui/react/icon';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
-import paperCss from '@luke-ui/react/themes/paper.css?url';
-import tactileCss from '@luke-ui/react/themes/tactile.css?url';
+import paperCss from '@luke-ui/react/themes/paper/stylesheet.css?url';
+import tactileCss from '@luke-ui/react/themes/tactile/stylesheet.css?url';
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router';
import type { SharedProps } from 'fumadocs-ui/components/dialog/search';
import { RootProvider } from 'fumadocs-ui/provider/tanstack';
@@ -18,6 +18,8 @@ export const Route = createRootRoute({
head: () => ({
links: [
{ href: appCss, rel: 'stylesheet' },
+ // Tactile must stay last: before hydration, the last stylesheet's `:where(:root)`
+ // fallback wins, and it has to match what `getServerThemeIdentity` returns.
{ href: paperCss, rel: 'stylesheet' },
{ href: tactileCss, rel: 'stylesheet' },
{
diff --git a/apps/docs/src/samples/overview/paper-theme.tsx b/apps/docs/src/samples/overview/paper-theme.tsx
index 4281d231..bd044dc7 100644
--- a/apps/docs/src/samples/overview/paper-theme.tsx
+++ b/apps/docs/src/samples/overview/paper-theme.tsx
@@ -1,9 +1,7 @@
-import '@luke-ui/react/themes/paper.css';
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { paperThemeClassName } from '@luke-ui/react/themes';
-import { cx } from '@luke-ui/react/utils';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import { rootClassName } from '@luke-ui/react/theme';
import type { PropsWithChildren } from 'react';
export function App({ children }: PropsWithChildren) {
- return
{children}
;
+ return
{children}
;
}
diff --git a/apps/docs/src/samples/styling/static-styles.tsx b/apps/docs/src/samples/styling/static-styles.tsx
index ec064fba..bd9c952f 100644
--- a/apps/docs/src/samples/styling/static-styles.tsx
+++ b/apps/docs/src/samples/styling/static-styles.tsx
@@ -1,10 +1,8 @@
import '@luke-ui/react/stylesheet.css';
-import '@luke-ui/react/themes/tactile.css';
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
-import { cx } from '@luke-ui/react/utils';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { rootClassName } from '@luke-ui/react/theme';
import type { PropsWithChildren } from 'react';
export function App({ children }: PropsWithChildren) {
- return
{children}
;
+ return
{children}
;
}
diff --git a/apps/docs/src/samples/theming/apply-theme.tsx b/apps/docs/src/samples/theming/apply-theme.tsx
index 0b53e0ab..671661bb 100644
--- a/apps/docs/src/samples/theming/apply-theme.tsx
+++ b/apps/docs/src/samples/theming/apply-theme.tsx
@@ -1,13 +1,11 @@
import '@luke-ui/react/stylesheet.css';
-import '@luke-ui/react/themes/tactile.css';
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
-import { cx } from '@luke-ui/react/utils';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { rootClassName } from '@luke-ui/react/theme';
import type { PropsWithChildren } from 'react';
export function App({ children }: PropsWithChildren) {
return (
-
+
{children}
);
diff --git a/apps/docs/src/samples/theming/custom-theme-app.tsx b/apps/docs/src/samples/theming/custom-theme-app.tsx
index b92d6925..23f13d8d 100644
--- a/apps/docs/src/samples/theming/custom-theme-app.tsx
+++ b/apps/docs/src/samples/theming/custom-theme-app.tsx
@@ -1,5 +1,4 @@
-import { themeClassName, themeRootClassName } from '@luke-ui/react/theme';
-import { cx } from '@luke-ui/react/utils';
+import { rootClassName } from '@luke-ui/react/theme';
import type { PropsWithChildren } from 'react';
type AppProps = PropsWithChildren<{ themeStylesheetHref: string }>;
@@ -8,7 +7,7 @@ export function App({ children, themeStylesheetHref }: AppProps) {
return (
<>
-
{children}
+
{children}
>
);
}
diff --git a/apps/docs/src/samples/theming/getting-started.tsx b/apps/docs/src/samples/theming/getting-started.tsx
index ead330ef..a4c0b684 100644
--- a/apps/docs/src/samples/theming/getting-started.tsx
+++ b/apps/docs/src/samples/theming/getting-started.tsx
@@ -1,14 +1,12 @@
import '@luke-ui/react/stylesheet.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { Text } from '@luke-ui/react/text';
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
-import { cx } from '@luke-ui/react/utils';
+import { rootClassName } from '@luke-ui/react/theme';
import type { PropsWithChildren } from 'react';
export function App({ children }: PropsWithChildren) {
return (
-
+
Hello world
{children}
diff --git a/apps/docs/src/samples/theming/multi-theme-app.tsx b/apps/docs/src/samples/theming/multi-theme-app.tsx
new file mode 100644
index 00000000..00efbe9f
--- /dev/null
+++ b/apps/docs/src/samples/theming/multi-theme-app.tsx
@@ -0,0 +1,19 @@
+import '@luke-ui/react/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
+import { getThemeClassName, rootClassName } from '@luke-ui/react/theme';
+import { cx } from '@luke-ui/react/utils';
+import type { PropsWithChildren } from 'react';
+
+// The same kebab-case `name` the product theme's `ThemeInput` declares.
+const productThemeClassName = getThemeClassName('product');
+
+type AppProps = PropsWithChildren<{ productStylesheetHref: string }>;
+
+export function App({ children, productStylesheetHref }: AppProps) {
+ return (
+ <>
+
+
{children}
+ >
+ );
+}
diff --git a/docs/STYLING.md b/docs/STYLING.md
index 2e18ff02..0befd6fe 100644
--- a/docs/STYLING.md
+++ b/docs/STYLING.md
@@ -3,9 +3,10 @@
## Setup
Luke UI ships one static stylesheet for its reset, theme root, recipes, and utilities. Consumers
-import `@luke-ui/react/stylesheet.css` and apply `themeRootClassName` from `@luke-ui/react/theme`
-near the app root. Import one bundled theme stylesheet and apply its identity class to the same
-element. Neither step injects styles at runtime.
+import `@luke-ui/react/stylesheet.css` and apply `rootClassName` from `@luke-ui/react/theme` to
+``, ``, or an app shell. Import one bundled theme stylesheet, for example
+`@luke-ui/react/themes/tactile/stylesheet.css`. That alone themes the whole document from `:root`,
+with no class and no JS required. Neither step injects styles at runtime.
## Structure
@@ -61,10 +62,20 @@ element. Neither step injects styles at runtime.
- `theme/token-board.tsx`: the contract-driven "Theme/Token board" Storybook story, which renders
every contract leaf for the active theme and colour mode.
- `theme/build-theme.ts`: the internal `compileTheme(foundation) → { css, diagnostics }` value
- pipeline, `buildTheme`, `themeClassName`, and contrast validation.
-- `theme/foundations.ts`: `defineTheme(...)` inputs for the bundled Tactile and Paper themes.
-- `themes/`: bundled theme class-name constants exported from `@luke-ui/react/themes`.
-- `scripts/build-themes.ts`: writes the bundled theme stylesheets to `dist/themes/`.
+ pipeline, `buildTheme`, and contrast validation.
+- `theme/theme-class-name.ts`: `getThemeClassName(name)`, the one home for the identity class and
+ its kebab-case rule, exported from `@luke-ui/react/theme`. It imports nothing, so importing a
+ class never drags in the compiler or a foundation.
+- `theme/foundations/tactile.ts` and `theme/foundations/paper.ts`: each bundled theme's
+ `defineTheme(...)` input, kept as separate leaf modules so importing one never pulls in the other.
+- `themes/tactile/` and `themes/paper/`: each theme's public entrypoint, exported from
+ `@luke-ui/react/themes/tactile` and `@luke-ui/react/themes/paper`. Each exports its own
+ `themeClassName` identity class and its `theme` (the public `ThemeInput` a consumer can read,
+ copy, or spread). The class comes from a per-theme `theme-class-name.ts` leaf holding the name as
+ a literal, so importing the class alone leaves the foundation out of a consumer's bundle.
+ `themes/theme-bundle.test.ts` proves that with a real bundler run.
+- `scripts/build-themes.ts`: writes the bundled theme stylesheets to
+ `dist/themes//stylesheet.css`, alongside the entrypoint `vp pack` emits there.
## Themes
@@ -103,10 +114,17 @@ Use `deriveConcentricRadius(innerRadius, gap)` for rounded elements nested insid
surface. It returns a CSS `calc()` value for the outer radius, so both inputs can be semantic theme
variables instead of theme-specific numbers.
-The bundled themes ship precompiled. Import `@luke-ui/react/themes/tactile.css` or
-`@luke-ui/react/themes/paper.css` and apply the matching `tactileThemeClassName` or
-`paperThemeClassName` constant from `@luke-ui/react/themes` to `` or a subtree root. Importing
-one theme never pulls in the other.
+The bundled themes ship precompiled. Import `@luke-ui/react/themes/tactile/stylesheet.css` or
+`@luke-ui/react/themes/paper/stylesheet.css` alone to theme the whole document from `:root`, with no
+class applied anywhere. Each stylesheet pairs a `:where(:root)` fallback with its own
+`.luke-ui-theme-` identity class, so importing one theme never pulls in the other.
+
+Apply the theme's `themeClassName`, from `@luke-ui/react/themes/tactile` or
+`@luke-ui/react/themes/paper`, only when a document needs more than one theme active at once, for
+example a marketing page next to an app shell. Scope it to `` or a subtree root alongside the
+matching stylesheet. An authored theme reaches the same class through `getThemeClassName(name)` from
+`@luke-ui/react/theme`, so a `defineTheme` theme is applied by exactly the mechanism a bundled one
+is.
Without `data-color-mode`, a themed subtree follows `prefers-color-scheme`. Setting
`data-color-mode="light"` or `data-color-mode="dark"` on the theme root, an ancestor, or any element
@@ -115,10 +133,11 @@ inside the subtree forces that mode, and nested scopes can override it. Every sc
Components move to the semantic contract in the component-family migration slices.
-Luke UI's portalled Combobox popover carries the nearest identity class and explicit colour mode
-from its trigger. Portals created by an application must apply the same public class and attribute
-contract to their portal root. When no explicit mode exists, omit `data-color-mode` so the portalled
-surface continues to follow the system preference.
+Luke UI's portalled Combobox popover inherits the document's theme. It carries no theme identity or
+colour mode propagation logic of its own, because a loaded theme stylesheet already themes the whole
+document from `:root`. A colour mode scoped to a nested element below `` does not reach a
+body-level portal. Set `data-color-mode` on `` itself when a portalled surface must follow an
+explicit mode.
## Cascade layers
diff --git a/packages/@luke-ui/react/.storybook/preview.tsx b/packages/@luke-ui/react/.storybook/preview.tsx
index 9dd1a6fd..c97b5320 100644
--- a/packages/@luke-ui/react/.storybook/preview.tsx
+++ b/packages/@luke-ui/react/.storybook/preview.tsx
@@ -1,12 +1,14 @@
///
-import '../dist/themes/paper.css';
-import '../dist/themes/tactile.css';
+import '../dist/themes/paper/stylesheet.css';
+import '../dist/themes/tactile/stylesheet.css';
import '@luke-ui/react/stylesheet.css';
import { IconSpritesheetProvider } from '@luke-ui/react/icon';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
-import { themeRootClassName, vars } from '@luke-ui/react/theme';
-import { paperThemeClassName, tactileThemeClassName } from '@luke-ui/react/themes';
+import { rootClassName, vars } from '@luke-ui/react/theme';
+import { themeClassName as paperThemeClassName } from '@luke-ui/react/themes/paper';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
+import { cx } from '@luke-ui/react/utils';
import addonA11y from '@storybook/addon-a11y';
import addonDocs from '@storybook/addon-docs';
import { definePreview } from '@storybook/react-vite';
@@ -27,7 +29,7 @@ export default definePreview({
) : null}
{/* your app */}
;
+ return
{/* your app */}
;
}
```
+Loading more than one theme stylesheet in the same document needs an explicit identity class so one
+theme wins. Import it from that theme's own entrypoint, for example
+`@luke-ui/react/themes/tactile`'s `themeClassName`.
+
## Components and docs
Full component documentation, interactive examples, and API reference are at
diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json
index 92cae60d..6256b36d 100644
--- a/packages/@luke-ui/react/package.json
+++ b/packages/@luke-ui/react/package.json
@@ -16,7 +16,7 @@
"sideEffects": [
"**/*.css.ts",
"./dist/stylesheet.css",
- "./dist/themes/*.css"
+ "./dist/themes/*/stylesheet.css"
],
"exports": {
"./blockquote": "./dist/blockquote/index.js",
@@ -49,14 +49,15 @@
"./text-field": "./dist/text-field/index.js",
"./text-field/primitive": "./dist/text-field/primitive/index.js",
"./theme": "./dist/theme/index.js",
- "./themes": "./dist/themes/index.js",
+ "./themes/paper": "./dist/themes/paper/index.js",
+ "./themes/tactile": "./dist/themes/tactile/index.js",
"./utils": "./dist/utils/index.js",
"./visually-hidden": "./dist/visually-hidden/index.js",
"./package.json": "./package.json",
"./stylesheet.css": "./dist/stylesheet.css",
"./spritesheet.svg": "./dist/spritesheet.svg",
- "./themes/tactile.css": "./dist/themes/tactile.css",
- "./themes/paper.css": "./dist/themes/paper.css"
+ "./themes/tactile/stylesheet.css": "./dist/themes/tactile/stylesheet.css",
+ "./themes/paper/stylesheet.css": "./dist/themes/paper/stylesheet.css"
},
"publishConfig": {
"access": "public"
diff --git a/packages/@luke-ui/react/scripts/build-themes.ts b/packages/@luke-ui/react/scripts/build-themes.ts
index eb407f13..d7e40f5b 100644
--- a/packages/@luke-ui/react/scripts/build-themes.ts
+++ b/packages/@luke-ui/react/scripts/build-themes.ts
@@ -4,7 +4,8 @@ import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, writeFile } from 'node:fs/promises';
import { defineTheme } from '../src/theme/define-theme.js';
-import { paperTheme, tactileTheme } from '../src/theme/foundations.js';
+import { paperTheme } from '../src/theme/foundations/paper.js';
+import { tactileTheme } from '../src/theme/foundations/tactile.js';
const themes = [tactileTheme, paperTheme];
@@ -12,11 +13,11 @@ async function main() {
await Promise.all(
themes.map(async (theme) => {
const outputPath = fileURLToPath(
- new URL(`../dist/themes/${theme.name}.css`, import.meta.url),
+ new URL(`../dist/themes/${theme.name}/stylesheet.css`, import.meta.url),
);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, defineTheme(theme), 'utf8');
- process.stdout.write(`Generated dist/themes/${theme.name}.css\n`);
+ process.stdout.write(`Generated dist/themes/${theme.name}/stylesheet.css\n`);
}),
);
}
diff --git a/packages/@luke-ui/react/src/box/box.browser.test.tsx b/packages/@luke-ui/react/src/box/box.browser.test.tsx
index 06c2140e..c59f516b 100644
--- a/packages/@luke-ui/react/src/box/box.browser.test.tsx
+++ b/packages/@luke-ui/react/src/box/box.browser.test.tsx
@@ -1,12 +1,12 @@
-import '../../dist/themes/tactile.css';
+import '../../dist/themes/tactile/stylesheet.css';
import '../stylesheet.css.js';
import { act, createRef } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, test } from 'vite-plus/test';
import { page } from 'vite-plus/test/context';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { Box } from './index.js';
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
@@ -22,7 +22,7 @@ afterEach(async () => {
test('renders a responsive layout at the retained breakpoints', async () => {
const container = document.body.appendChild(document.createElement('div'));
- container.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ container.className = `${rootClassName} ${tactileThemeClassName}`;
const root = createRoot(container);
mounted.push({ container, root });
diff --git a/packages/@luke-ui/react/src/button/button.browser.test.tsx b/packages/@luke-ui/react/src/button/button.browser.test.tsx
index 8e32b2c4..dd471756 100644
--- a/packages/@luke-ui/react/src/button/button.browser.test.tsx
+++ b/packages/@luke-ui/react/src/button/button.browser.test.tsx
@@ -1,4 +1,4 @@
-import '../../dist/themes/tactile.css';
+import '../../dist/themes/tactile/stylesheet.css';
import '../stylesheet.css.js';
import { act } from 'react';
import type { ReactNode } from 'react';
@@ -8,8 +8,8 @@ import { afterEach, expect, test } from 'vite-plus/test';
import spritesheetHref from '../../dist/spritesheet.svg?url';
import { IconButton } from '../icon-button/index.js';
import { Icon, IconSpritesheetProvider } from '../icon/index.js';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { Button } from './index.js';
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
@@ -84,7 +84,7 @@ test('an explicit icon size overrides the button-provided icon size', () => {
function mountFixture(node: ReactNode) {
const container = document.body.appendChild(document.createElement('div'));
- container.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ container.className = `${rootClassName} ${tactileThemeClassName}`;
const root = createRoot(container);
mounted.push({ container, root });
diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx
index 99de7324..d9c8651d 100644
--- a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx
+++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx
@@ -12,7 +12,6 @@ import {
Stack,
visualAppearances,
} from '../test-utils/render-visual.js';
-import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js';
import { ComboboxField } from './index.js';
import { ComboboxItem, ComboboxLoadMoreItem } from './primitive/item.js';
import { ComboboxSection } from './primitive/section.js';
@@ -316,41 +315,6 @@ for (const appearance of visualAppearances) {
});
}
-for (const appearance of visualAppearances) {
- test(`nested opposite mode reaches the portal: ${appearance.theme} ${appearance.mode}`, async () => {
- const nestedMode = appearance.mode === 'light' ? 'dark' : 'light';
- renderVisual(
-
-
-
- {renderCountryItem}
-
-
-
,
- appearance,
- );
-
- await userEvent.click(page.getByRole('combobox', { name: 'Themed country' }));
- const listbox = page.getByRole('listbox');
- const portal = listbox.element().closest('[data-color-mode]');
-
- expect(portal).toHaveClass(
- appearance.theme === 'paper' ? paperThemeClassName : tactileThemeClassName,
- );
- expect(portal).toHaveAttribute('data-color-mode', nestedMode);
- await captureVisualAppearance(
- page.elementLocator(document.body),
- 'combobox-field/nested-opposite-mode-portal',
- appearance,
- );
- });
-}
-
for (const appearance of visualAppearances) {
test(`mobile tray: ${appearance.theme} ${appearance.mode}`, async () => {
renderVisual(
diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx
index 32bc7765..7ab39f6b 100644
--- a/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx
+++ b/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx
@@ -5,7 +5,7 @@ import type { PopoverProps as RacPopoverProps } from 'react-aria-components/Comb
import { Popover as RacPopover } from 'react-aria-components/ComboBox';
import { composeRenderProps } from 'react-aria-components/composeRenderProps';
import * as styles from '../../recipes/combobox.css.js';
-import { themeRootClassName } from '../../theme/index.js';
+import { rootClassName } from '../../theme/index.js';
import type { DistributiveOmit } from '../../types/distributive-omit.js';
import type { Prettify } from '../../types/prettify.js';
import { cx } from '../../utils/index.js';
@@ -25,8 +25,9 @@ interface _ComboboxPopoverProps extends _ComboboxPopoverOmit {
export type ComboboxPopoverProps = Prettify<_ComboboxPopoverProps>;
/**
- * Popover surface used for listbox content. The portal preserves the theme identity and explicit
- * colour mode active at its trigger.
+ * Popover surface used for listbox content. The portal inherits the document's theme: importing a
+ * theme stylesheet themes the whole document from `:root`, so no propagation is needed. A colour
+ * mode scoped below `` does not reach the portal.
*/
export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element {
const { ref, ...restProps } = props;
@@ -37,41 +38,9 @@ export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element {
{
- return cx(themeRootClassName, styles.combobox().popover(className));
- })}
- ref={mergeRefs(ref, (node: HTMLElement | null) => {
- setElement(node);
- if (node === null) return;
-
- const themeSource = props.triggerRef?.current ?? document.activeElement;
- if (!(themeSource instanceof Element)) return;
-
- carryThemeScope(themeSource, node);
+ return cx(rootClassName, styles.combobox().popover(className));
})}
+ ref={mergeRefs(ref, setElement)}
/>
);
}
-
-function carryThemeScope(source: Element, portal: HTMLElement) {
- const identityClassName = findThemeIdentity(source);
- if (identityClassName !== undefined) portal.classList.add(identityClassName);
-
- const modeRoot = source.closest('[data-color-mode]');
- const mode = modeRoot?.dataset.colorMode;
- if (mode === 'light' || mode === 'dark') portal.dataset.colorMode = mode;
-}
-
-function findThemeIdentity(source: Element) {
- let identityClassName: string | undefined;
- let ancestor: Element | null = source;
-
- while (ancestor !== null) {
- for (const className of ancestor.classList) {
- // Keep the outer identity because nested identities are not supported.
- if (className.startsWith('luke-ui-theme-')) identityClassName = className;
- }
- ancestor = ancestor.parentElement;
- }
-
- return identityClassName;
-}
diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx
index a2e2b55a..3958e1d1 100644
--- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx
+++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx
@@ -71,7 +71,7 @@ for (const appearance of visualAppearances) {
appearance,
);
- await expect.element(scene).toHaveAttribute('data-color-mode', appearance.mode);
+ expect(document.documentElement).toHaveAttribute('data-color-mode', appearance.mode);
await captureVisualAppearance(scene, 'loading-spinner/theme-matrix', appearance);
});
}
diff --git a/packages/@luke-ui/react/src/recipes/button.browser.test.ts b/packages/@luke-ui/react/src/recipes/button.browser.test.ts
index fc720595..a9f088fb 100644
--- a/packages/@luke-ui/react/src/recipes/button.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/button.browser.test.ts
@@ -1,9 +1,9 @@
import '../styles/reset.css.js';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
import { cdp } from 'vite-plus/test/context';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { button } from './button.css.js';
let mounted: Array = [];
@@ -151,7 +151,7 @@ test('reduced motion removes hover and press travel', async () => {
function mountButton(options: Parameters[0] = {}) {
const root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ root.className = `${rootClassName} ${tactileThemeClassName}`;
root.dataset.colorMode = 'light';
const control = root.appendChild(document.createElement('button'));
control.className = button(options);
diff --git a/packages/@luke-ui/react/src/recipes/checkbox.browser.test.ts b/packages/@luke-ui/react/src/recipes/checkbox.browser.test.ts
index a232e735..8b9ef856 100644
--- a/packages/@luke-ui/react/src/recipes/checkbox.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/checkbox.browser.test.ts
@@ -1,9 +1,9 @@
import '../styles/reset.css.js';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
import { fontSizeSteps } from '../theme/contract.js';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { checkbox } from './checkbox.css.js';
import { field as fieldRecipe } from './field.css.js';
import { text } from './text.css.js';
@@ -93,7 +93,7 @@ test('sizes inherit from the root into the control, indicator, and field message
test('ordinary field messages keep their zero indentation fallback', () => {
const root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ root.className = `${rootClassName} ${tactileThemeClassName}`;
const message = root.appendChild(document.createElement('span'));
message.className = fieldRecipe({ tone: 'description' }).message();
mounted.push(root);
@@ -139,7 +139,7 @@ function mountCheckbox(
textSize?: (typeof fontSizeSteps)[number],
) {
const root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ root.className = `${rootClassName} ${tactileThemeClassName}`;
root.dataset.colorMode = 'light';
root.style.lineHeight = '24px';
const textElement = root.appendChild(document.createElement('span'));
diff --git a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts
index 8641e72f..0d7dae4e 100644
--- a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts
@@ -1,8 +1,8 @@
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
import { cdp } from 'vite-plus/test/context';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { cx } from '../utils/index.js';
import { combobox } from './combobox.css.js';
@@ -263,7 +263,7 @@ test('reduced motion makes control, action, option, and popover state changes im
function mountControl(size: 'medium' | 'small' = 'medium') {
const root = document.body.appendChild(document.createElement('div'));
- root.className = cx(themeRootClassName, tactileThemeClassName);
+ root.className = cx(rootClassName, tactileThemeClassName);
root.dataset.colorMode = 'light';
wrappers.push(root);
const control = root.appendChild(document.createElement('div'));
diff --git a/packages/@luke-ui/react/src/recipes/field.browser.test.ts b/packages/@luke-ui/react/src/recipes/field.browser.test.ts
index 60975cbe..f0457c98 100644
--- a/packages/@luke-ui/react/src/recipes/field.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/field.browser.test.ts
@@ -1,7 +1,7 @@
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { cx } from '../utils/index.js';
import { field } from './field.css.js';
@@ -87,7 +87,7 @@ test('disabled field text takes the functional disabled colour, not a role colou
function mountField(options: Parameters[0] = {}) {
const root = document.body.appendChild(document.createElement('div'));
- root.className = cx(themeRootClassName, tactileThemeClassName);
+ root.className = cx(rootClassName, tactileThemeClassName);
root.dataset.colorMode = 'light';
mounted.push(root);
diff --git a/packages/@luke-ui/react/src/recipes/input-group.browser.test.ts b/packages/@luke-ui/react/src/recipes/input-group.browser.test.ts
index 081e7869..79caeac4 100644
--- a/packages/@luke-ui/react/src/recipes/input-group.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/input-group.browser.test.ts
@@ -1,7 +1,7 @@
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { cx } from '../utils/index.js';
import { inputGroup } from './input-group.css.js';
@@ -166,7 +166,7 @@ test('prefix divider uses the control border color and disabled text color follo
function mountGroup(options: Parameters[0] = {}) {
const root = document.body.appendChild(document.createElement('div'));
- root.className = cx(themeRootClassName, tactileThemeClassName);
+ root.className = cx(rootClassName, tactileThemeClassName);
root.dataset.colorMode = 'light';
const group = root.appendChild(document.createElement('div'));
group.className = inputGroup(options).group();
diff --git a/packages/@luke-ui/react/src/recipes/link.browser.test.ts b/packages/@luke-ui/react/src/recipes/link.browser.test.ts
index 5e089fa0..80271a9c 100644
--- a/packages/@luke-ui/react/src/recipes/link.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/link.browser.test.ts
@@ -1,8 +1,8 @@
import '../styles/reset.css.js';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
-import { themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { link } from './link.css.js';
let mounted: Array = [];
@@ -82,7 +82,7 @@ test('focus-visible shows the complete independent semantic ring', () => {
function mountLink(options: Parameters[0] = {}) {
const root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ root.className = `${rootClassName} ${tactileThemeClassName}`;
root.dataset.colorMode = 'light';
const anchor = root.appendChild(document.createElement('a'));
anchor.className = link(options);
diff --git a/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts b/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts
index 9bd08eae..b53346a7 100644
--- a/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts
@@ -1,10 +1,11 @@
-import '@luke-ui/react/themes/paper.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
import { cdp } from 'vite-plus/test/context';
import { contrastRatio, parseColor } from '../theme/color.js';
-import { themeRootClassName } from '../theme/index.js';
-import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js';
+import { rootClassName } from '../theme/index.js';
+import { themeClassName as paperThemeClassName } from '../themes/paper/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { loadingSkeletonClassName } from './loading-skeleton.css.js';
let root: HTMLElement | undefined;
@@ -59,7 +60,7 @@ function mountInlineSkeleton(
mode: 'light' | 'dark' = 'light',
) {
root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${themeClassName}`;
+ root.className = `${rootClassName} ${themeClassName}`;
root.dataset.colorMode = mode;
root.style.backgroundColor = 'var(--luke-color-surface-canvas)';
const skeleton = root.appendChild(document.createElement('span'));
@@ -190,7 +191,7 @@ test('paints a pseudo-element over children in block mode', async () => {
function mountBlockSkeleton() {
root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ root.className = `${rootClassName} ${tactileThemeClassName}`;
root.dataset.colorMode = 'light';
root.style.backgroundColor = 'var(--luke-color-surface-canvas)';
const parent = root.appendChild(document.createElement('div'));
diff --git a/packages/@luke-ui/react/src/recipes/text.browser.test.ts b/packages/@luke-ui/react/src/recipes/text.browser.test.ts
index 0069b98a..97c2eaea 100644
--- a/packages/@luke-ui/react/src/recipes/text.browser.test.ts
+++ b/packages/@luke-ui/react/src/recipes/text.browser.test.ts
@@ -1,9 +1,9 @@
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { afterEach, expect, test } from 'vite-plus/test';
import { fontSizeSteps } from '../theme/contract.js';
-import { tactileTheme } from '../theme/foundations.js';
-import { defineTheme, themeClassName, themeRootClassName } from '../theme/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { tactileTheme } from '../theme/foundations/tactile.js';
+import { defineTheme, getThemeClassName, rootClassName } from '../theme/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { text, textLineHeight } from './text.css.js';
let mounted: Array = [];
@@ -206,7 +206,7 @@ function mountTextLineHeightControl(parent: HTMLElement) {
function mountRoot(themeClass = tactileThemeClassName) {
const root = document.body.appendChild(document.createElement('div'));
- root.className = `${themeRootClassName} ${themeClass}`;
+ root.className = `${rootClassName} ${themeClass}`;
root.dataset.colorMode = 'light';
mounted.push(root);
return root;
@@ -221,7 +221,7 @@ function installTheme(fontFamily: 'inter' | 'apple-system' | 'dm-sans') {
typography: { fontFamily },
});
styles.push(style);
- return themeClassName(name);
+ return getThemeClassName(name);
}
const curatedFamilyIdentity = {
diff --git a/packages/@luke-ui/react/src/styles/package-exports.test.ts b/packages/@luke-ui/react/src/styles/package-exports.test.ts
index 919423de..d30c9aff 100644
--- a/packages/@luke-ui/react/src/styles/package-exports.test.ts
+++ b/packages/@luke-ui/react/src/styles/package-exports.test.ts
@@ -4,7 +4,8 @@ import packageJson from '../../package.json' with { type: 'json' };
test('publishes only the final styling entrypoints', () => {
expect(packageJson.exports['./box']).toBe('./dist/box/index.js');
expect(packageJson.exports['./theme']).toBe('./dist/theme/index.js');
- expect(packageJson.exports['./themes']).toBe('./dist/themes/index.js');
+ expect(packageJson.exports['./themes/tactile']).toBe('./dist/themes/tactile/index.js');
+ expect(packageJson.exports['./themes/paper']).toBe('./dist/themes/paper/index.js');
expect(packageJson.exports['./recipes']).toBe('./dist/recipes/index.js');
expect(packageJson.exports['./styles']).toBe('./dist/styles/index.js');
expect(packageJson.exports['./stylesheet.css']).toBe('./dist/stylesheet.css');
diff --git a/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx b/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx
index d8ede47c..61f9cbea 100644
--- a/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx
+++ b/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx
@@ -1,8 +1,8 @@
import '@luke-ui/react/stylesheet.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import { Icon, IconSpritesheetProvider } from '@luke-ui/react/icon';
-import { themeRootClassName } from '@luke-ui/react/theme';
-import { tactileThemeClassName } from '@luke-ui/react/themes';
+import { rootClassName } from '@luke-ui/react/theme';
+import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile';
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
@@ -48,7 +48,7 @@ test('applies the public reset, theme, and icon-size contracts', () => {
function mountFixture() {
const container = document.body.appendChild(document.createElement('div'));
- container.className = `${themeRootClassName} ${tactileThemeClassName}`;
+ container.className = `${rootClassName} ${tactileThemeClassName}`;
const root = createRoot(container);
mounted.push({ container, root });
diff --git a/packages/@luke-ui/react/src/styles/utilities.browser.test.ts b/packages/@luke-ui/react/src/styles/utilities.browser.test.ts
index 2f27c9f5..93af818a 100644
--- a/packages/@luke-ui/react/src/styles/utilities.browser.test.ts
+++ b/packages/@luke-ui/react/src/styles/utilities.browser.test.ts
@@ -1,8 +1,8 @@
-import '../../dist/themes/tactile.css';
+import '../../dist/themes/tactile/stylesheet.css';
import '../stylesheet.css.js';
import { afterEach, expect, test } from 'vite-plus/test';
import { page } from 'vite-plus/test/context';
-import { tactileThemeClassName } from '../themes/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { mergeProps } from '../utils/index.js';
import { createSprinkles } from './utilities.css.js';
diff --git a/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx b/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx
index f59bf616..31b7d0de 100644
--- a/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx
+++ b/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx
@@ -1,5 +1,6 @@
import { expect, test } from 'vite-plus/test';
-import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js';
+import { themeClassName as paperThemeClassName } from '../themes/paper/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import { cleanupVisual, renderVisual, visualAppearances } from './render-visual.js';
test('renders every bundled identity and explicit colour mode independently', () => {
@@ -7,10 +8,10 @@ test('renders every bundled identity and explicit colour mode independently', ()
const scene = renderVisual(Theme contract, appearance);
const root = scene.element();
- expect(root).toHaveClass(
+ expect(document.documentElement).toHaveClass(
appearance.theme === 'tactile' ? tactileThemeClassName : paperThemeClassName,
);
- expect(root).toHaveAttribute('data-color-mode', appearance.mode);
+ expect(document.documentElement).toHaveAttribute('data-color-mode', appearance.mode);
const styles = getComputedStyle(root);
expect(styles.colorScheme).toBe(appearance.mode);
expect(styles.backgroundColor).toBe(styles.getPropertyValue('--luke-color-surface-canvas'));
@@ -20,10 +21,10 @@ test('renders every bundled identity and explicit colour mode independently', ()
});
test('defaults existing callers to Tactile light', () => {
- const root = renderVisual(Default contract).element();
+ renderVisual(Default contract);
- expect(root).toHaveClass(tactileThemeClassName);
- expect(root).toHaveAttribute('data-color-mode', 'light');
+ expect(document.documentElement).toHaveClass(tactileThemeClassName);
+ expect(document.documentElement).toHaveAttribute('data-color-mode', 'light');
});
test('allows a nested scope to select the opposite colour mode', () => {
diff --git a/packages/@luke-ui/react/src/test-utils/render-visual.tsx b/packages/@luke-ui/react/src/test-utils/render-visual.tsx
index 30335ecb..676f7a99 100644
--- a/packages/@luke-ui/react/src/test-utils/render-visual.tsx
+++ b/packages/@luke-ui/react/src/test-utils/render-visual.tsx
@@ -2,8 +2,8 @@
// Loads the design-token stylesheet into the test document.
import '../stylesheet.css.js';
-import '@luke-ui/react/themes/paper.css';
-import '@luke-ui/react/themes/tactile.css';
+import '@luke-ui/react/themes/paper/stylesheet.css';
+import '@luke-ui/react/themes/tactile/stylesheet.css';
import type { ComponentProps, ComponentType, CSSProperties, ReactNode } from 'react';
import { act } from 'react';
import type { Root } from 'react-dom/client';
@@ -15,14 +15,17 @@ import { cdp, page, userEvent } from 'vite-plus/test/context';
// both `build` and `test` depend on, so it is always present when tests run.
import spritesheetHref from '../../dist/spritesheet.svg?url';
import { IconSpritesheetProvider } from '../icon/index.js';
-import { themeRootClassName, vars } from '../theme/index.js';
-import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js';
-import { cx } from '../utils/index.js';
+import { rootClassName, vars } from '../theme/index.js';
+import { themeClassName as paperThemeClassName } from '../themes/paper/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
+/** The identity class currently applied to `document.documentElement`, if any. */
+let appliedIdentityClassName: string | undefined;
+
export type VisualAppearance = {
mode: 'light' | 'dark';
theme: 'tactile' | 'paper';
@@ -41,11 +44,22 @@ const defaultVisualAppearance: VisualAppearance = visualAppearances[0];
* Renders `node` inside the same theme root and icon spritesheet provider the
* app (and Storybook) wrap components with, then returns a Vitest locator for
* the mounted subtree ready to pass to `captureVisual`.
+ *
+ * The identity class and colour mode go on `document.documentElement`, not the
+ * container, so a portal (combobox popover, mobile tray) that mounts outside
+ * the container still gets the intended theme and mode.
*/
export function renderVisual(node: ReactNode, appearance = defaultVisualAppearance) {
+ const identityClassName = identityClassNameFor(appearance.theme);
+ if (appliedIdentityClassName != null) {
+ document.documentElement.classList.remove(appliedIdentityClassName);
+ }
+ document.documentElement.classList.add(identityClassName);
+ appliedIdentityClassName = identityClassName;
+ document.documentElement.dataset.colorMode = appearance.mode;
+
const container = document.body.appendChild(document.createElement('div'));
- container.className = cx(themeRootClassName, getThemeClassName(appearance.theme));
- container.dataset.colorMode = appearance.mode;
+ container.className = rootClassName;
container.style.backgroundColor = vars.color.surface.canvas;
const root = createRoot(container);
mounted.push({ container, root });
@@ -57,7 +71,7 @@ export function renderVisual(node: ReactNode, appearance = defaultVisualAppearan
return page.elementLocator(container);
}
-function getThemeClassName(theme: VisualAppearance['theme']) {
+function identityClassNameFor(theme: VisualAppearance['theme']) {
return theme === 'tactile' ? tactileThemeClassName : paperThemeClassName;
}
@@ -210,6 +224,12 @@ export function cleanupVisual() {
container.remove();
}
mounted.length = 0;
+
+ if (appliedIdentityClassName != null) {
+ document.documentElement.classList.remove(appliedIdentityClassName);
+ appliedIdentityClassName = undefined;
+ }
+ delete document.documentElement.dataset.colorMode;
}
/**
diff --git a/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts b/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts
index 09372b32..3ca2ca73 100644
--- a/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts
+++ b/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts
@@ -6,7 +6,8 @@
*/
import { normalizeTheme } from '../define-theme.js';
-import { paperTheme, tactileTheme } from '../foundations.js';
+import { paperTheme } from '../foundations/paper.js';
+import { tactileTheme } from '../foundations/tactile.js';
// The bundled themes are authored as `defineTheme` inputs; these engine tests exercise the raw
// `buildTheme` pipeline directly, so resolve each input into the foundation `buildTheme` consumes.
diff --git a/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/paper.v2.css b/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/paper.v2.css
index 43a65364..eff1ad04 100644
--- a/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/paper.v2.css
+++ b/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/paper.v2.css
@@ -1,4 +1,5 @@
/* Generated by buildTheme from @luke-ui/react. Do not edit. */
+:where(:root),
.luke-ui-theme-paper {
--luke-font-100-baseline-trim: -0.3029em;
--luke-font-100-cap-height-trim: -0.3029em;
@@ -79,6 +80,7 @@
--luke-motion-easing-exit: cubic-bezier(0.3, 0, 1, 1);
}
+:where(:root),
.luke-ui-theme-paper {
color-scheme: light;
--luke-color-surface-canvas: oklch(1 0 0);
@@ -164,6 +166,7 @@
}
@media (prefers-color-scheme: dark) {
+ :where(:root),
.luke-ui-theme-paper {
color-scheme: dark;
--luke-color-surface-canvas: oklch(0.22 0.01 250);
@@ -249,6 +252,8 @@
}
}
+:where(:root[data-color-mode='light']),
+:where(:root) :where([data-color-mode='light']),
.luke-ui-theme-paper[data-color-mode='light'],
.luke-ui-theme-paper [data-color-mode='light'],
[data-color-mode='light'] .luke-ui-theme-paper {
@@ -335,6 +340,8 @@
--luke-action-control-finish-raised: radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.2) 0%, transparent 100%), radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%);
}
+:where(:root[data-color-mode='dark']),
+:where(:root) :where([data-color-mode='dark']),
.luke-ui-theme-paper[data-color-mode='dark'],
.luke-ui-theme-paper [data-color-mode='dark'],
[data-color-mode='dark'] .luke-ui-theme-paper {
diff --git a/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/tactile.v2.css b/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/tactile.v2.css
index 0fc41dcc..22f6f9e3 100644
--- a/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/tactile.v2.css
+++ b/packages/@luke-ui/react/src/theme/__fixtures__/v2-goldens/tactile.v2.css
@@ -1,4 +1,5 @@
/* Generated by buildTheme from @luke-ui/react. Do not edit. */
+:where(:root),
.luke-ui-theme-tactile {
--luke-font-100-baseline-trim: -0.3029em;
--luke-font-100-cap-height-trim: -0.3029em;
@@ -79,6 +80,7 @@
--luke-motion-easing-exit: cubic-bezier(0.3, 0, 1, 1);
}
+:where(:root),
.luke-ui-theme-tactile {
color-scheme: light;
--luke-color-surface-canvas: oklch(0.985 0 0);
@@ -164,6 +166,7 @@
}
@media (prefers-color-scheme: dark) {
+ :where(:root),
.luke-ui-theme-tactile {
color-scheme: dark;
--luke-color-surface-canvas: oklch(0.25 0.015 210);
@@ -249,6 +252,8 @@
}
}
+:where(:root[data-color-mode='light']),
+:where(:root) :where([data-color-mode='light']),
.luke-ui-theme-tactile[data-color-mode='light'],
.luke-ui-theme-tactile [data-color-mode='light'],
[data-color-mode='light'] .luke-ui-theme-tactile {
@@ -335,6 +340,8 @@
--luke-action-control-finish-raised: radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.3) 0%, transparent 100%), radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.16) 0%, transparent 70%);
}
+:where(:root[data-color-mode='dark']),
+:where(:root) :where([data-color-mode='dark']),
.luke-ui-theme-tactile[data-color-mode='dark'],
.luke-ui-theme-tactile [data-color-mode='dark'],
[data-color-mode='dark'] .luke-ui-theme-tactile {
diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts
index 8d77a6b0..2b95c396 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.ts
@@ -65,8 +65,7 @@ export function buildTheme(foundation: ThemeFoundation): string {
}
// Re-exported so `./build-theme.js` stays the one import path for the compiler's public surface,
-// now that the theme-name solver and the failure shape live in their own modules.
-export { themeClassName } from './theme-class-name.js';
+// now that the failure shape lives in its own module.
export type { ThemeContrastFailure } from './contrast-validation.js';
/**
diff --git a/packages/@luke-ui/react/src/theme/cascade.browser.test.ts b/packages/@luke-ui/react/src/theme/cascade.browser.test.ts
new file mode 100644
index 00000000..048cca9f
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/cascade.browser.test.ts
@@ -0,0 +1,221 @@
+/**
+ * Pins the `:where(:root)` cascade contract from `stylesheet.ts`. A loaded theme stylesheet themes
+ * the whole document with no class applied. An explicit identity class always wins over another
+ * loaded theme's fallback. Both hold regardless of the order the stylesheets load in.
+ *
+ * Nested identity classes still collide: an ancestor's `.X [data-color-mode='M']` rule and a nested
+ * identity's own rule share specificity (0,2,0), so they resolve by stylesheet order. Nested
+ * identities are unsupported and guarded elsewhere, and this file does not test that case.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test';
+import { cdp } from 'vite-plus/test/context';
+import paperCss from '../../dist/themes/paper/stylesheet.css?inline';
+import tactileCss from '../../dist/themes/tactile/stylesheet.css?inline';
+import { themeClassName as paperThemeClassName } from '../themes/paper/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
+import { extractValue, splitBlocks } from './__fixtures__/theme-css.js';
+
+const themeCss = { paper: paperCss, tactile: tactileCss } as const;
+type ThemeName = keyof typeof themeCss;
+
+const tactileBlocks = splitBlocks(tactileCss);
+const paperBlocks = splitBlocks(paperCss);
+
+const tactileRadius = extractValue(tactileBlocks.identity, '--luke-radius-control');
+const paperRadius = extractValue(paperBlocks.identity, '--luke-radius-control');
+const tactileLightCanvas = extractValue(tactileBlocks.baseLight, '--luke-color-surface-canvas');
+const tactileDarkCanvas = extractValue(tactileBlocks.mediaDark, '--luke-color-surface-canvas');
+const paperLightCanvas = extractValue(paperBlocks.baseLight, '--luke-color-surface-canvas');
+const paperDarkCanvas = extractValue(paperBlocks.mediaDark, '--luke-color-surface-canvas');
+
+it('keeps every theme and mode combination distinct, so a resolved match below cannot pass by luck', () => {
+ const canvases = [tactileLightCanvas, tactileDarkCanvas, paperLightCanvas, paperDarkCanvas];
+ expect(new Set(canvases).size).toBe(canvases.length);
+ const radii = [tactileRadius, paperRadius];
+ expect(new Set(radii).size).toBe(radii.length);
+});
+
+const injectedStyles: Array = [];
+const createdElements: Array = [];
+
+function injectStylesheet(name: ThemeName): void {
+ const style = document.createElement('style');
+ style.textContent = themeCss[name];
+ document.head.append(style);
+ injectedStyles.push(style);
+}
+
+function createDiv(parent: Element): HTMLDivElement {
+ const div = document.createElement('div');
+ parent.append(div);
+ createdElements.push(div);
+ return div;
+}
+
+function readVar(element: Element, varName: string): string {
+ return getComputedStyle(element).getPropertyValue(varName).trim();
+}
+
+async function emulateColorScheme(mode: 'light' | 'dark'): Promise {
+ await cdp().send('Emulation.setEmulatedMedia', {
+ features: [{ name: 'prefers-color-scheme', value: mode }],
+ });
+}
+
+afterEach(async () => {
+ for (const style of injectedStyles) style.remove();
+ injectedStyles.length = 0;
+ for (const element of createdElements) element.remove();
+ createdElements.length = 0;
+ document.documentElement.className = '';
+ document.documentElement.removeAttribute('data-color-mode');
+ await emulateColorScheme('light');
+});
+
+type Scenario = {
+ description: string;
+ expected: string;
+ target: () => Element;
+ varName: '--luke-color-surface-canvas' | '--luke-radius-control';
+};
+
+// Each scenario names the theme it expects to win, so a passing assertion is not a coincidence.
+function scenarios(): Array {
+ return [
+ {
+ description:
+ "a plain descendant resolves paper's radius when carries the paper identity class",
+ expected: paperRadius,
+ target: () => {
+ document.documentElement.className = paperThemeClassName;
+ return createDiv(document.body);
+ },
+ varName: '--luke-radius-control',
+ },
+ {
+ description:
+ "a plain descendant resolves tactile's radius when carries the tactile identity class",
+ expected: tactileRadius,
+ target: () => {
+ document.documentElement.className = tactileThemeClassName;
+ return createDiv(document.body);
+ },
+ varName: '--luke-radius-control',
+ },
+ {
+ description:
+ "a plain descendant resolves paper's dark canvas when carries the paper identity class and data-color-mode='dark'",
+ expected: paperDarkCanvas,
+ target: () => {
+ document.documentElement.className = paperThemeClassName;
+ document.documentElement.dataset.colorMode = 'dark';
+ return createDiv(document.body);
+ },
+ varName: '--luke-color-surface-canvas',
+ },
+ {
+ description:
+ "a nested data-color-mode='dark' div resolves paper's dark canvas inside a div.luke-ui-theme-paper, with no identity on ",
+ expected: paperDarkCanvas,
+ target: () => {
+ const outer = createDiv(document.body);
+ outer.className = paperThemeClassName;
+ const inner = createDiv(outer);
+ inner.dataset.colorMode = 'dark';
+ return inner;
+ },
+ varName: '--luke-color-surface-canvas',
+ },
+ {
+ description:
+ "a nested data-color-mode='dark' div resolves tactile's dark canvas inside a div.luke-ui-theme-tactile, with no identity on ",
+ expected: tactileDarkCanvas,
+ target: () => {
+ const outer = createDiv(document.body);
+ outer.className = tactileThemeClassName;
+ const inner = createDiv(outer);
+ inner.dataset.colorMode = 'dark';
+ return inner;
+ },
+ varName: '--luke-color-surface-canvas',
+ },
+ {
+ description:
+ "a class-less div appended straight to resolves paper's dark canvas from 's identity and data-color-mode, standing in for a portal",
+ expected: paperDarkCanvas,
+ target: () => {
+ document.documentElement.className = paperThemeClassName;
+ document.documentElement.dataset.colorMode = 'dark';
+ return createDiv(document.body);
+ },
+ varName: '--luke-color-surface-canvas',
+ },
+ {
+ description:
+ "a div.luke-ui-theme-paper resolves its own radius when nested inside ",
+ expected: paperRadius,
+ target: () => {
+ document.documentElement.className = tactileThemeClassName;
+ const paperDiv = createDiv(document.body);
+ paperDiv.className = paperThemeClassName;
+ return paperDiv;
+ },
+ varName: '--luke-radius-control',
+ },
+ ];
+}
+
+const stylesheetOrders: ReadonlyArray = [
+ ['tactile', 'paper'],
+ ['paper', 'tactile'],
+];
+
+for (const order of stylesheetOrders) {
+ describe(`stylesheets loaded ${order[0]} then ${order[1]}`, () => {
+ beforeEach(() => {
+ injectStylesheet(order[0]);
+ injectStylesheet(order[1]);
+ });
+
+ for (const scenario of scenarios()) {
+ it(`${scenario.description}`, () => {
+ const target = scenario.target();
+ expect(readVar(target, scenario.varName)).toBe(scenario.expected);
+ });
+ }
+ });
+}
+
+// The primary consumer contract: importing one theme stylesheet themes the whole document for
+// free, with no class applied anywhere.
+describe('a single stylesheet with no identity class applied anywhere', () => {
+ beforeEach(async () => {
+ injectStylesheet('tactile');
+ await emulateColorScheme('light');
+ });
+
+ it("resolves tactile's light canvas on a plain descendant", () => {
+ const container = createDiv(document.body);
+ const target = createDiv(container);
+ expect(readVar(target, '--luke-color-surface-canvas')).toBe(tactileLightCanvas);
+ });
+
+ it("resolves tactile's light canvas on a div appended straight to ", () => {
+ const target = createDiv(document.body);
+ expect(readVar(target, '--luke-color-surface-canvas')).toBe(tactileLightCanvas);
+ });
+
+ it("resolves tactile's dark canvas on a nested data-color-mode='dark' div", () => {
+ const container = createDiv(document.body);
+ const target = createDiv(container);
+ target.dataset.colorMode = 'dark';
+ expect(readVar(target, '--luke-color-surface-canvas')).toBe(tactileDarkCanvas);
+ });
+
+ it("resolves tactile's dark canvas on a plain descendant when carries data-color-mode='dark'", () => {
+ document.documentElement.dataset.colorMode = 'dark';
+ const target = createDiv(document.body);
+ expect(readVar(target, '--luke-color-surface-canvas')).toBe(tactileDarkCanvas);
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/define-theme.test.ts b/packages/@luke-ui/react/src/theme/define-theme.test.ts
index 64b82659..1f13a368 100644
--- a/packages/@luke-ui/react/src/theme/define-theme.test.ts
+++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vite-plus/test';
import { gamutMapOklch, parseColor } from './color.js';
import { flattenThemeContract } from './contract.js';
import { defaultDepth, defineTheme, normalizeTheme } from './define-theme.js';
-import { paperTheme, tactileTheme } from './foundations.js';
+import { paperTheme } from './foundations/paper.js';
+import { tactileTheme } from './foundations/tactile.js';
import { generateFamilyWithDiagnostics } from './scale.js';
/**
diff --git a/packages/@luke-ui/react/src/theme/foundations.ts b/packages/@luke-ui/react/src/theme/foundations.ts
deleted file mode 100644
index 75402a20..00000000
--- a/packages/@luke-ui/react/src/theme/foundations.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import type { ThemeInput } from './define-theme.js';
-
-/**
- * Tactile, the default bundled theme: a teal accent, a neutral near-white light canvas, lighter
- * chromatic dark surfaces, and a compact tactile material. Both modes are authored explicitly, so
- * `defineTheme` uses each side verbatim.
- */
-export const tactileTheme: ThemeInput = {
- actionControlFinish: {
- dark: {
- raised: [
- 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.18) 0%, transparent 100%)',
- 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)',
- ].join(', '),
- recessed: [
- 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)',
- 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.04) 0%, transparent 70%)',
- ].join(', '),
- resting: [
- 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.14) 0%, transparent 100%)',
- 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)',
- ].join(', '),
- },
- light: {
- raised: [
- 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.3) 0%, transparent 100%)',
- 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.16) 0%, transparent 70%)',
- ].join(', '),
- recessed: [
- 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.12) 0%, transparent 100%)',
- 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.06) 0%, transparent 70%)',
- ].join(', '),
- resting: [
- 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.24) 0%, transparent 100%)',
- 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.12) 0%, transparent 70%)',
- ].join(', '),
- },
- },
- color: {
- accent: { dark: 'oklch(0.75 0.1 200)', light: 'oklch(0.52 0.11 200)' },
- neutral: { dark: 'oklch(0.25 0.015 210)', light: 'oklch(0.985 0 0)' },
- },
- depth: {
- dark: {
- floating: [
- '0 4px 12px oklch(0.05 0.01 220 / 0.38)',
- '0 2px 4px oklch(0.05 0.01 220 / 0.22)',
- ].join(', '),
- overlay: [
- '0 12px 32px oklch(0.05 0.01 220 / 0.5)',
- '0 4px 12px oklch(0.05 0.01 220 / 0.28)',
- ].join(', '),
- raised: [
- '0 3px 0 oklch(0.05 0.01 220 / 0.55)',
- '0 5px 8px -2px oklch(0.05 0.01 220 / 0.32)',
- ].join(', '),
- recessed: [
- 'inset 0 2px 4px oklch(0.05 0.01 220 / 0.45)',
- 'inset 0 -1px 0 oklch(0.8 0.01 220 / 0.12)',
- ].join(', '),
- resting: [
- '0 2px 0 oklch(0.05 0.01 220 / 0.5)',
- '0 3px 5px -1px oklch(0.05 0.01 220 / 0.26)',
- ].join(', '),
- },
- light: {
- floating: [
- '0 4px 12px oklch(0.3 0.03 220 / 0.16)',
- '0 2px 4px oklch(0.3 0.03 220 / 0.1)',
- ].join(', '),
- overlay: [
- '0 12px 32px oklch(0.3 0.03 220 / 0.2)',
- '0 4px 12px oklch(0.3 0.03 220 / 0.12)',
- ].join(', '),
- raised: [
- '0 3px 0 oklch(0.3 0.03 220 / 0.3)',
- '0 5px 8px -2px oklch(0.3 0.03 220 / 0.2)',
- ].join(', '),
- recessed: [
- 'inset 0 2px 3px oklch(0.3 0.03 220 / 0.18)',
- 'inset 0 -1px 0 oklch(0.98 0.03 220 / 0.65)',
- ].join(', '),
- resting: [
- '0 2px 0 oklch(0.3 0.03 220 / 0.28)',
- '0 3px 5px -1px oklch(0.3 0.03 220 / 0.16)',
- ].join(', '),
- },
- },
- name: 'tactile',
-};
-
-/**
- * Paper, the materially minimal bundled theme. Its light mode approximates the flat,
- * hairline-bordered Luke UI look with the blue `#185281`-family accent and explicitly authored status
- * colours; its dark mode is net-new and lets the `info`, `success`, and `warning` roles fall back to
- * the curated mode defaults (their dark sides are omitted).
- */
-export const paperTheme: ThemeInput = {
- actionControlFinish: {
- dark: {
- raised: [
- 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.1) 0%, transparent 100%)',
- 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.05) 0%, transparent 70%)',
- ].join(', '),
- recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.04) 0%, transparent 100%)',
- resting: [
- 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.07) 0%, transparent 100%)',
- 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.03) 0%, transparent 70%)',
- ].join(', '),
- },
- light: {
- raised: [
- 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.2) 0%, transparent 100%)',
- 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)',
- ].join(', '),
- recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)',
- resting: [
- 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.16) 0%, transparent 100%)',
- 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)',
- ].join(', '),
- },
- },
- color: {
- accent: { dark: 'oklch(0.7 0.11 250)', light: '#185281' },
- // Feedback colours are authored for light only; the omitted dark sides default per mode.
- danger: { light: '#c0262e' },
- info: { light: '#1d39c4' },
- neutral: { dark: 'oklch(0.22 0.01 250)', light: '#ffffff' },
- success: { light: '#306317' },
- warning: { light: '#d89614' },
- },
- depth: {
- dark: {
- floating: '0 4px 14px oklch(0.12 0.01 250 / 0.25)',
- overlay: '0 12px 36px oklch(0.12 0.01 250 / 0.32)',
- raised: [
- '0 2px 6px oklch(0.12 0.01 250 / 0.18)',
- '0 1px 3px oklch(0.12 0.01 250 / 0.12)',
- ].join(', '),
- recessed: 'inset 0 1px 2px oklch(0.12 0.01 250 / 0.22)',
- resting: [
- '0 1px 3px oklch(0.12 0.01 250 / 0.12)',
- '0 1px 2px oklch(0.12 0.01 250 / 0.06)',
- ].join(', '),
- },
- light: {
- floating: '0 4px 14px oklch(0.2 0.01 250 / 0.12)',
- overlay: '0 12px 36px oklch(0.2 0.01 250 / 0.16)',
- raised: [
- '0 2px 6px oklch(0.2 0.01 250 / 0.05)',
- '0 1px 3px oklch(0.2 0.01 250 / 0.035)',
- ].join(', '),
- recessed: 'none',
- resting: [
- '0 1px 3px oklch(0.2 0.01 250 / 0.04)',
- '0 1px 2px oklch(0.2 0.01 250 / 0.02)',
- ].join(', '),
- },
- },
- name: 'paper',
- radius: { control: 4 },
-};
diff --git a/packages/@luke-ui/react/src/theme/foundations/paper.ts b/packages/@luke-ui/react/src/theme/foundations/paper.ts
new file mode 100644
index 00000000..595af43b
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/foundations/paper.ts
@@ -0,0 +1,59 @@
+import type { ThemeInput } from '../define-theme.js';
+
+/**
+ * Paper, the materially minimal bundled theme. Its light mode approximates the flat,
+ * hairline-bordered Luke UI look with the blue `#185281`-family accent and explicitly authored status
+ * colours; its dark mode is net-new and lets the `info`, `success`, and `warning` roles fall back to
+ * the curated mode defaults (their dark sides are omitted).
+ */
+// Multi-layer values below are concatenated string literals, not `[...].join(', ')`, because a
+// joined value survives dead-code elimination even when unused. See `themes/theme-bundle.test.ts`.
+export const paperTheme: ThemeInput = {
+ actionControlFinish: {
+ dark: {
+ raised:
+ 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.1) 0%, transparent 100%), ' +
+ 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.05) 0%, transparent 70%)',
+ recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.04) 0%, transparent 100%)',
+ resting:
+ 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.07) 0%, transparent 100%), ' +
+ 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.03) 0%, transparent 70%)',
+ },
+ light: {
+ raised:
+ 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.2) 0%, transparent 100%), ' +
+ 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)',
+ recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)',
+ resting:
+ 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.16) 0%, transparent 100%), ' +
+ 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)',
+ },
+ },
+ color: {
+ accent: { dark: 'oklch(0.7 0.11 250)', light: '#185281' },
+ // Feedback colours are authored for light only; the omitted dark sides default per mode.
+ danger: { light: '#c0262e' },
+ info: { light: '#1d39c4' },
+ neutral: { dark: 'oklch(0.22 0.01 250)', light: '#ffffff' },
+ success: { light: '#306317' },
+ warning: { light: '#d89614' },
+ },
+ depth: {
+ dark: {
+ floating: '0 4px 14px oklch(0.12 0.01 250 / 0.25)',
+ overlay: '0 12px 36px oklch(0.12 0.01 250 / 0.32)',
+ raised: '0 2px 6px oklch(0.12 0.01 250 / 0.18), 0 1px 3px oklch(0.12 0.01 250 / 0.12)',
+ recessed: 'inset 0 1px 2px oklch(0.12 0.01 250 / 0.22)',
+ resting: '0 1px 3px oklch(0.12 0.01 250 / 0.12), 0 1px 2px oklch(0.12 0.01 250 / 0.06)',
+ },
+ light: {
+ floating: '0 4px 14px oklch(0.2 0.01 250 / 0.12)',
+ overlay: '0 12px 36px oklch(0.2 0.01 250 / 0.16)',
+ raised: '0 2px 6px oklch(0.2 0.01 250 / 0.05), 0 1px 3px oklch(0.2 0.01 250 / 0.035)',
+ recessed: 'none',
+ resting: '0 1px 3px oklch(0.2 0.01 250 / 0.04), 0 1px 2px oklch(0.2 0.01 250 / 0.02)',
+ },
+ },
+ name: 'paper',
+ radius: { control: 4 },
+};
diff --git a/packages/@luke-ui/react/src/theme/foundations/tactile.ts b/packages/@luke-ui/react/src/theme/foundations/tactile.ts
new file mode 100644
index 00000000..d4bd0255
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/foundations/tactile.ts
@@ -0,0 +1,58 @@
+import type { ThemeInput } from '../define-theme.js';
+
+/**
+ * Tactile, the default bundled theme: a teal accent, a neutral near-white light canvas, lighter
+ * chromatic dark surfaces, and a compact tactile material. Both modes are authored explicitly, so
+ * `defineTheme` uses each side verbatim.
+ */
+// Multi-layer values below are concatenated string literals, not `[...].join(', ')`, because a
+// joined value survives dead-code elimination even when unused. See `themes/theme-bundle.test.ts`.
+export const tactileTheme: ThemeInput = {
+ actionControlFinish: {
+ dark: {
+ raised:
+ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.18) 0%, transparent 100%), ' +
+ 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)',
+ recessed:
+ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%), ' +
+ 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.04) 0%, transparent 70%)',
+ resting:
+ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.14) 0%, transparent 100%), ' +
+ 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)',
+ },
+ light: {
+ raised:
+ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.3) 0%, transparent 100%), ' +
+ 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.16) 0%, transparent 70%)',
+ recessed:
+ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.12) 0%, transparent 100%), ' +
+ 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.06) 0%, transparent 70%)',
+ resting:
+ 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.24) 0%, transparent 100%), ' +
+ 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.12) 0%, transparent 70%)',
+ },
+ },
+ color: {
+ accent: { dark: 'oklch(0.75 0.1 200)', light: 'oklch(0.52 0.11 200)' },
+ neutral: { dark: 'oklch(0.25 0.015 210)', light: 'oklch(0.985 0 0)' },
+ },
+ depth: {
+ dark: {
+ floating: '0 4px 12px oklch(0.05 0.01 220 / 0.38), 0 2px 4px oklch(0.05 0.01 220 / 0.22)',
+ overlay: '0 12px 32px oklch(0.05 0.01 220 / 0.5), 0 4px 12px oklch(0.05 0.01 220 / 0.28)',
+ raised: '0 3px 0 oklch(0.05 0.01 220 / 0.55), 0 5px 8px -2px oklch(0.05 0.01 220 / 0.32)',
+ recessed:
+ 'inset 0 2px 4px oklch(0.05 0.01 220 / 0.45), inset 0 -1px 0 oklch(0.8 0.01 220 / 0.12)',
+ resting: '0 2px 0 oklch(0.05 0.01 220 / 0.5), 0 3px 5px -1px oklch(0.05 0.01 220 / 0.26)',
+ },
+ light: {
+ floating: '0 4px 12px oklch(0.3 0.03 220 / 0.16), 0 2px 4px oklch(0.3 0.03 220 / 0.1)',
+ overlay: '0 12px 32px oklch(0.3 0.03 220 / 0.2), 0 4px 12px oklch(0.3 0.03 220 / 0.12)',
+ raised: '0 3px 0 oklch(0.3 0.03 220 / 0.3), 0 5px 8px -2px oklch(0.3 0.03 220 / 0.2)',
+ recessed:
+ 'inset 0 2px 3px oklch(0.3 0.03 220 / 0.18), inset 0 -1px 0 oklch(0.98 0.03 220 / 0.65)',
+ resting: '0 2px 0 oklch(0.3 0.03 220 / 0.28), 0 3px 5px -1px oklch(0.3 0.03 220 / 0.16)',
+ },
+ },
+ name: 'tactile',
+};
diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx
index 931959b2..8b6c0005 100644
--- a/packages/@luke-ui/react/src/theme/index.tsx
+++ b/packages/@luke-ui/react/src/theme/index.tsx
@@ -1,8 +1,12 @@
import { lukeUiClassNames } from '../styles/class-names.js';
import { cx } from '../utils/index.js';
-/** Convenience class name combining the theme-root and CSS-reset classes. */
-export const themeRootClassName = cx(lukeUiClassNames.themeRoot, lukeUiClassNames.resetRoot);
+/**
+ * Applies the descendant CSS reset and the base Luke UI typography and theme layer. It carries no
+ * theme identity of its own. Apply it to ``, ``, an app shell, or any element you
+ * already own.
+ */
+export const rootClassName = cx(lukeUiClassNames.themeRoot, lukeUiClassNames.resetRoot);
/**
* Typed access to the semantic theme custom properties. Each path resolves to a stable global
@@ -21,15 +25,14 @@ export { fontSizeSteps } from './contract.js';
export type { FontSizeStep } from './contract.js';
/**
- * `themeClassName(name)` returns the identity class for a theme name. `ThemeContrastError` is thrown
- * by `defineTheme` when a hard-gated pair misses WCAG 2.2 AA: 4.5:1 for text/on-solid pairs, 3:1 for
- * the focus ring and `border.control`. The six semantic `border.` pairs are measured but
- * advisory only and cannot trigger this error. It carries every failing mode-and-pair in its `failures`
- * array. `ThemeGenerationError` is thrown when a role that must guarantee on-solid contrast (an
- * inaccessible explicit per-mode accent, for example) cannot reach an accessible solid. It names the
- * failing `role` and `mode`.
+ * `ThemeContrastError` is thrown by `defineTheme` when a hard-gated pair misses WCAG 2.2 AA: 4.5:1
+ * for text/on-solid pairs, 3:1 for the focus ring and `border.control`. The six semantic
+ * `border.` pairs are measured but advisory only and cannot trigger this error. It carries
+ * every failing mode-and-pair in its `failures` array. `ThemeGenerationError` is thrown when a role
+ * that must guarantee on-solid contrast (an inaccessible explicit per-mode accent, for example)
+ * cannot reach an accessible solid. It names the failing `role` and `mode`.
*/
-export { ThemeContrastError, ThemeGenerationError, themeClassName } from './build-theme.js';
+export { ThemeContrastError, ThemeGenerationError } from './build-theme.js';
/** One WCAG contrast failure recorded on a {@link ThemeContrastError}. */
export type { ThemeContrastFailure } from './build-theme.js';
@@ -49,6 +52,14 @@ export type { ColorInput, ControlFinish, DepthLadder, ThemeInput } from './defin
/** Curated defaults `defineTheme` applies for omitted materials and scrim. */
export { defaultControlFinish, defaultDepth, defaultScrim } from './define-theme.js';
+/**
+ * `getThemeClassName(name)` returns a theme's identity class, `luke-ui-theme-${name}`. Pass the same
+ * `name` the theme's {@link ThemeInput} declares. Apply the result to the theme root when one
+ * document loads more than one theme stylesheet, so the explicit identity outranks another theme's
+ * `:root` fallback. It throws when the name is not the kebab-case `defineTheme` requires.
+ */
+export { getThemeClassName } from './theme-class-name.js';
+
/** Derives a concentric outer corner from an inner radius plus the intervening gap. */
export { deriveConcentricRadius } from './foundation.js';
diff --git a/packages/@luke-ui/react/src/theme/stylesheet.test.ts b/packages/@luke-ui/react/src/theme/stylesheet.test.ts
index f249fd69..b47d8581 100644
--- a/packages/@luke-ui/react/src/theme/stylesheet.test.ts
+++ b/packages/@luke-ui/react/src/theme/stylesheet.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vite-plus/test';
-import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js';
+import { themeClassName as paperThemeClassName } from '../themes/paper/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
import {
extractValue,
paperFoundation,
@@ -40,12 +41,34 @@ describe('buildTheme output', () => {
expect(blocks.baseLight).toContain('color-scheme: light;');
expect(blocks.mediaDark).toContain('color-scheme: dark;');
for (const mode of ['light', 'dark']) {
+ expect(css).toContain(`:where(:root[data-color-mode='${mode}']),`);
+ expect(css).toContain(`:where(:root) :where([data-color-mode='${mode}']),`);
expect(css).toContain(`.luke-ui-theme-tactile[data-color-mode='${mode}'],`);
expect(css).toContain(`.luke-ui-theme-tactile [data-color-mode='${mode}'],`);
expect(css).toContain(`[data-color-mode='${mode}'] .luke-ui-theme-tactile {`);
}
});
+ it('pairs every rule block with a zero-specificity `:where(:root)` fallback', () => {
+ for (const block of [
+ blocks.identity,
+ blocks.baseLight,
+ blocks.mediaDark,
+ blocks.explicitLight,
+ blocks.explicitDark,
+ ]) {
+ expect(block).toContain(':where(:root');
+ }
+ });
+
+ it('never emits a bare `:root` selector outside of `:where()`', () => {
+ const rootIndexes = [...css.matchAll(/:root/g)].map((match) => match.index);
+ expect(rootIndexes.length).toBeGreaterThan(0);
+ for (const index of rootIndexes) {
+ expect(css.slice(index - ':where('.length, index)).toBe(':where(');
+ }
+ });
+
it('declares every identity variable exactly once in the identity block', () => {
const counts = identityVarNames.map((varName) => {
return [varName, countOccurrences(blocks.identity, `${varName}: `)];
diff --git a/packages/@luke-ui/react/src/theme/stylesheet.ts b/packages/@luke-ui/react/src/theme/stylesheet.ts
index e68e5669..39d57109 100644
--- a/packages/@luke-ui/react/src/theme/stylesheet.ts
+++ b/packages/@luke-ui/react/src/theme/stylesheet.ts
@@ -14,7 +14,7 @@ import {
defaultRadius,
themeFontFamilyStacks,
} from './foundation.js';
-import { themeClassName } from './theme-class-name.js';
+import { getThemeClassName } from './theme-class-name.js';
import {
CONTROL_SIZE_VALUES,
FONT_METRICS,
@@ -27,16 +27,17 @@ import {
type ColorMode = 'light' | 'dark';
/**
- * Emits the five rule blocks: an identity rule carrying every non-mode contract leaf, the base light
- * rule, the `prefers-color-scheme: dark` media rule, and the two explicit `data-color-mode` rules.
- * Throws when a contract leaf has no resolved value.
+ * Emits the five rule blocks: an identity rule, the base light rule, the
+ * `prefers-color-scheme: dark` media rule, and the two explicit `data-color-mode` rules. The
+ * identity rule carries every non-mode contract leaf. Throws when a contract leaf has no resolved
+ * value.
*/
export function assembleStylesheet(
foundation: ThemeFoundation,
lightValues: Record,
darkValues: Record,
): string {
- const selector = `.${themeClassName(foundation.name)}`;
+ const selector = `.${getThemeClassName(foundation.name)}`;
const pairs = flattenThemeContract();
const isModePath = (path: string) => {
return (
@@ -52,12 +53,17 @@ export function assembleStylesheet(
const lightDeclarations = ['color-scheme: light;', ...declarations(modePairs, lightValues)];
const darkDeclarations = ['color-scheme: dark;', ...declarations(modePairs, darkValues)];
- // Without data-color-mode, the base light rule plus the prefers-color-scheme media query follow
- // the system preference. The explicit attribute rules are specificity (0,2,0), so they beat the
- // (0,1,0) media-query rule whether the attribute sits on the theme root, inside its subtree, or
- // on an ancestor. Every scope sets native color-scheme, and the output is unlayered on purpose.
+ // Every rule pairs a `:where(:root)` fallback with the identity class, so one stylesheet themes
+ // the whole document, including body-level portals. `:where()` zeroes the fallback's specificity
+ // to (0,0,0), so an explicit identity always outranks another loaded theme's fallback, whatever
+ // the stylesheet order. The explicit attribute rules are (0,2,0), so they beat the (0,1,0)
+ // media-query rule wherever the attribute sits, whether on the theme root, inside its subtree,
+ // or on an ancestor. Every scope sets native color-scheme, and the output is unlayered on purpose.
+ const rootAndIdentitySelector = [':where(:root),', `${selector} {`];
const attributeSelectors = (attributeMode: ColorMode) => {
return [
+ `:where(:root[data-color-mode='${attributeMode}']),`,
+ `:where(:root) :where([data-color-mode='${attributeMode}']),`,
`${selector}[data-color-mode='${attributeMode}'],`,
`${selector} [data-color-mode='${attributeMode}'],`,
`[data-color-mode='${attributeMode}'] ${selector} {`,
@@ -66,16 +72,16 @@ export function assembleStylesheet(
return [
'/* Generated by buildTheme from @luke-ui/react. Do not edit. */',
- `${selector} {`,
+ ...rootAndIdentitySelector,
...identityDeclarations.map(indent),
'}',
'',
- `${selector} {`,
+ ...rootAndIdentitySelector,
...lightDeclarations.map(indent),
'}',
'',
'@media (prefers-color-scheme: dark) {',
- indent(`${selector} {`),
+ ...rootAndIdentitySelector.map(indent),
...darkDeclarations.map(indent).map(indent),
indent('}'),
'}',
diff --git a/packages/@luke-ui/react/src/theme/theme-class-name.test.ts b/packages/@luke-ui/react/src/theme/theme-class-name.test.ts
index 018b5944..13be6bc4 100644
--- a/packages/@luke-ui/react/src/theme/theme-class-name.test.ts
+++ b/packages/@luke-ui/react/src/theme/theme-class-name.test.ts
@@ -1,12 +1,53 @@
import { describe, expect, it } from 'vite-plus/test';
-import { themeClassName } from './theme-class-name.js';
-
-describe('themeClassName', () => {
- it('rejects theme names that are not kebab-case', () => {
- expect(() => themeClassName('Tactile')).toThrow(/kebab-case/);
- expect(() => themeClassName('-leading')).toThrow(/kebab-case/);
- expect(() => themeClassName('double--hyphen')).toThrow(/kebab-case/);
- expect(() => themeClassName('9lives')).toThrow(/kebab-case/);
- expect(themeClassName('tactile')).toBe('luke-ui-theme-tactile');
+import { defineTheme } from './define-theme.js';
+import { tactileTheme } from './foundations/tactile.js';
+import { getThemeClassName } from './theme-class-name.js';
+
+const VALID_NAMES = ['tactile', 'high-contrast', 'brand-v2', 'a1'];
+
+const INVALID_NAMES = [
+ 'Tactile',
+ '-leading',
+ 'trailing-',
+ 'double--hyphen',
+ '9lives',
+ '',
+ 'has space',
+ 'snake_case',
+];
+
+/**
+ * The message `getThemeClassName` rejects `name` with. Throws when the name is accepted, so a name
+ * that stops being invalid fails the test instead of silently asserting nothing.
+ */
+function kebabCaseError(name: string): string {
+ try {
+ getThemeClassName(name);
+ } catch (error) {
+ if (error instanceof Error) return error.message;
+ }
+ throw new Error(`expected getThemeClassName to reject "${name}"`);
+}
+
+describe('getThemeClassName', () => {
+ it('prefixes a kebab-case name', () => {
+ for (const name of VALID_NAMES) {
+ expect(getThemeClassName(name)).toBe(`luke-ui-theme-${name}`);
+ }
+ });
+
+ it('rejects a name that is not kebab-case', () => {
+ for (const name of INVALID_NAMES) {
+ expect(() => getThemeClassName(name)).toThrow(/kebab-case/);
+ }
+ });
+
+ // Pins the two surfaces together: an author can only reach a theme's class through this helper,
+ // so a name `defineTheme` accepts but the helper rejects would compile a stylesheet no identity
+ // class can select. Asserting on the identical message text, not just that both throw.
+ it('rejects the same names defineTheme does, with the same message', () => {
+ for (const name of INVALID_NAMES) {
+ expect(() => defineTheme({ ...tactileTheme, name })).toThrow(kebabCaseError(name));
+ }
});
});
diff --git a/packages/@luke-ui/react/src/theme/theme-class-name.ts b/packages/@luke-ui/react/src/theme/theme-class-name.ts
index b556d6a4..8372e1aa 100644
--- a/packages/@luke-ui/react/src/theme/theme-class-name.ts
+++ b/packages/@luke-ui/react/src/theme/theme-class-name.ts
@@ -1,9 +1,8 @@
/**
* Derives a theme's identity class from its name, and rejects a name that is not kebab-case.
*
- * A leaf module with no dependencies, because two modules that do not otherwise know about each
- * other both need it: `validate-foundation.ts` checks a foundation's `name` through it, and
- * `stylesheet.ts` needs the class as the emitted stylesheet's selector.
+ * This module must keep importing nothing. Staying dependency-free is what lets a consumer import
+ * one theme's class without pulling in the compiler, a foundation, or colour generation.
*/
const THEME_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -12,7 +11,7 @@ const THEME_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
* Returns the identity class for a theme name, `luke-ui-theme-${name}`. Throws when the name is
* not kebab-case.
*/
-export function themeClassName(name: string): string {
+export function getThemeClassName(name: string): string {
if (!THEME_NAME_PATTERN.test(name)) {
throw new Error(
`Theme name "${name}" must be kebab-case: lowercase letters and digits separated by ` +
diff --git a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx
index e65f0ff3..7cdac3b6 100644
--- a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx
+++ b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx
@@ -13,7 +13,8 @@ import type {
ThemeModeDiagnostics,
} from './diagnostics.js';
import type { GeneratedSurfaces } from './elevation.js';
-import { paperTheme, tactileTheme } from './foundations.js';
+import { paperTheme } from './foundations/paper.js';
+import { tactileTheme } from './foundations/tactile.js';
import type { FamilyRequirements, FamilyRole, ScaleFamily, ScaleStep } from './scale.js';
type BundledThemeKey = 'tactile' | 'paper';
diff --git a/packages/@luke-ui/react/src/theme/theme.browser.test.tsx b/packages/@luke-ui/react/src/theme/theme.browser.test.tsx
index b943db66..c37da69a 100644
--- a/packages/@luke-ui/react/src/theme/theme.browser.test.tsx
+++ b/packages/@luke-ui/react/src/theme/theme.browser.test.tsx
@@ -1,4 +1,4 @@
-import '../../dist/themes/tactile.css';
+import '../../dist/themes/tactile/stylesheet.css';
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
@@ -8,7 +8,7 @@ import { Button } from '../button/index.js';
import { ComboboxField } from '../combobox-field/index.js';
import { ComboboxItem } from '../combobox-field/primitive/item.js';
import { IconSpritesheetProvider } from '../icon/index.js';
-import { tactileThemeClassName } from '../themes/index.js';
+import { themeClassName as tactileThemeClassName } from '../themes/tactile/index.js';
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
const scopes: Array = [];
@@ -22,6 +22,7 @@ afterEach(async () => {
for (const scope of scopes) scope.remove();
scopes.length = 0;
+ document.documentElement.removeAttribute('data-color-mode');
await emulateColorScheme('light');
});
@@ -70,12 +71,11 @@ test('renders components from static CSS without theme context or injected style
expect(document.querySelectorAll('style')).toHaveLength(styleCount);
});
-test('preserves identity and an opposite nested mode on a portalled combobox', async () => {
- const outer = renderScope('light');
- const nested = outer.appendChild(document.createElement('div'));
- nested.dataset.colorMode = 'dark';
- const root = createRoot(nested);
- mounted.push({ container: outer, root });
+// Portal theme propagation was deliberately removed: importing a theme stylesheet themes the
+// whole document from `:root`, so a body-level portal inherits it with no JS or class needed.
+async function openPortalledCombobox(mountTarget: HTMLElement) {
+ const root = createRoot(mountTarget);
+ mounted.push({ container: mountTarget, root });
act(() => {
root.render(
@@ -98,11 +98,39 @@ test('preserves identity and an opposite nested mode on a portalled combobox', a
const portal = document.querySelector('[role="listbox"]')?.parentElement;
if (!portal) throw new Error('expected the listbox to have a popover parent');
- expect(portal).toHaveAttribute('data-color-mode', 'dark');
+ return portal;
+}
+
+test('a portalled combobox follows a colour mode set on the document', async () => {
+ document.documentElement.dataset.colorMode = 'dark';
+
+ const outer = renderScope('light');
+ const portal = await openPortalledCombobox(outer);
+
expect(getComputedStyle(portal).colorScheme).toBe('dark');
const portalCanvas = getComputedStyle(portal).getPropertyValue('--luke-color-surface-canvas');
expect(portalCanvas).not.toBe('');
expect(portalCanvas).toBe(
+ getComputedStyle(document.documentElement).getPropertyValue('--luke-color-surface-canvas'),
+ );
+});
+
+test('a colour mode scoped to a nested div does not reach a portalled combobox (propagation removed by design)', async () => {
+ const outer = renderScope('light');
+ const nested = outer.appendChild(document.createElement('div'));
+ nested.dataset.colorMode = 'dark';
+
+ const portal = await openPortalledCombobox(nested);
+
+ // The nested div's dark mode stays local to that subtree. The document carries no explicit
+ // mode, so the portal resolves the system preference instead, not the nested div's mode.
+ expect(portal).not.toHaveAttribute('data-color-mode');
+ expect(getComputedStyle(portal).colorScheme).not.toBe('dark');
+ const portalCanvas = getComputedStyle(portal).getPropertyValue('--luke-color-surface-canvas');
+ expect(portalCanvas).toBe(
+ getComputedStyle(document.documentElement).getPropertyValue('--luke-color-surface-canvas'),
+ );
+ expect(portalCanvas).not.toBe(
getComputedStyle(nested).getPropertyValue('--luke-color-surface-canvas'),
);
});
diff --git a/packages/@luke-ui/react/src/theme/validate-foundation.ts b/packages/@luke-ui/react/src/theme/validate-foundation.ts
index d1745e83..9cebf4ce 100644
--- a/packages/@luke-ui/react/src/theme/validate-foundation.ts
+++ b/packages/@luke-ui/react/src/theme/validate-foundation.ts
@@ -8,7 +8,7 @@
import { parseColor } from './color.js';
import type { ThemeFoundation } from './foundation.js';
import { SOURCE_COLOR_FIELDS, themeFontFamilyStacks } from './foundation.js';
-import { themeClassName } from './theme-class-name.js';
+import { getThemeClassName } from './theme-class-name.js';
/**
* Whether a value is unsafe to emit verbatim into the generated stylesheet: anything other than a
@@ -30,7 +30,7 @@ function isUnsafeCssValue(value: unknown): boolean {
export function validateFoundation(foundation: ThemeFoundation): void {
const issues: Array = [];
try {
- themeClassName(foundation.name);
+ getThemeClassName(foundation.name);
} catch (error) {
issues.push(`name: ${errorMessage(error)}`);
}
diff --git a/packages/@luke-ui/react/src/themes/index.test.ts b/packages/@luke-ui/react/src/themes/index.test.ts
deleted file mode 100644
index 1b479c33..00000000
--- a/packages/@luke-ui/react/src/themes/index.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { readFile } from 'node:fs/promises';
-import { describe, expect, it } from 'vite-plus/test';
-import packageJson from '../../package.json' with { type: 'json' };
-import { paperThemeClassName, tactileThemeClassName } from './index.js';
-
-const themeArtifacts = {
- paper: new URL('../../dist/themes/paper.css', import.meta.url),
- tactile: new URL('../../dist/themes/tactile.css', import.meta.url),
-} as const;
-
-describe('bundled theme artifacts', () => {
- it('exports each generated stylesheet as an independent package entrypoint', async () => {
- const paperCss = await readFile(themeArtifacts.paper, 'utf8');
- const tactileCss = await readFile(themeArtifacts.tactile, 'utf8');
-
- expect(packageJson.exports['./themes/paper.css']).toBe('./dist/themes/paper.css');
- expect(packageJson.exports['./themes/tactile.css']).toBe('./dist/themes/tactile.css');
- expect(paperCss).toContain(`.${paperThemeClassName} {`);
- expect(paperCss).not.toContain(tactileThemeClassName);
- expect(tactileCss).toContain(`.${tactileThemeClassName} {`);
- expect(tactileCss).not.toContain(paperThemeClassName);
- });
-});
diff --git a/packages/@luke-ui/react/src/themes/index.ts b/packages/@luke-ui/react/src/themes/index.ts
deleted file mode 100644
index e332c1d7..00000000
--- a/packages/@luke-ui/react/src/themes/index.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { themeClassName } from '../theme/build-theme.js';
-import { paperTheme, tactileTheme } from '../theme/foundations.js';
-
-/**
- * Identity class for the Tactile theme, the Luke UI default. Apply it to `` or a
- * subtree root together with the `@luke-ui/react/themes/tactile.css` stylesheet. Omit
- * `data-color-mode` to follow the system preference, or set it to `light` or `dark` on a nested
- * scope.
- */
-export const tactileThemeClassName = themeClassName(tactileTheme.name);
-
-/**
- * Identity class for the Paper theme. Apply it to `` or a subtree root together with the
- * `@luke-ui/react/themes/paper.css` stylesheet. Omit `data-color-mode` to follow the system
- * preference, or set it to `light` or `dark` on a nested scope.
- */
-export const paperThemeClassName = themeClassName(paperTheme.name);
diff --git a/packages/@luke-ui/react/src/themes/paper/index.ts b/packages/@luke-ui/react/src/themes/paper/index.ts
new file mode 100644
index 00000000..7dd850b6
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/paper/index.ts
@@ -0,0 +1,11 @@
+import type { ThemeInput } from '../../theme/define-theme.js';
+import { paperTheme } from '../../theme/foundations/paper.js';
+
+export { themeClassName } from './theme-class-name.js';
+
+/**
+ * Paper's `defineTheme` input, the materially minimal bundled theme: a flat, hairline-bordered
+ * look with a blue accent. Read it, copy it, or spread it into your own `defineTheme` call to
+ * start from Paper.
+ */
+export const theme: ThemeInput = paperTheme;
diff --git a/packages/@luke-ui/react/src/themes/paper/theme-class-name.ts b/packages/@luke-ui/react/src/themes/paper/theme-class-name.ts
new file mode 100644
index 00000000..69fd2c2f
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/paper/theme-class-name.ts
@@ -0,0 +1,11 @@
+/** Kept apart from `./index.ts` so the class never depends on the foundation. */
+
+import { getThemeClassName } from '../../theme/theme-class-name.js';
+
+const PAPER_THEME_NAME = 'paper';
+
+/**
+ * The Paper theme's identity class, needed only when a document loads more than one theme
+ * stylesheet. Apply it to the theme root, such as `` or a subtree root.
+ */
+export const themeClassName = getThemeClassName(PAPER_THEME_NAME);
diff --git a/packages/@luke-ui/react/src/themes/tactile/index.ts b/packages/@luke-ui/react/src/themes/tactile/index.ts
new file mode 100644
index 00000000..d8520137
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/tactile/index.ts
@@ -0,0 +1,11 @@
+import type { ThemeInput } from '../../theme/define-theme.js';
+import { tactileTheme } from '../../theme/foundations/tactile.js';
+
+export { themeClassName } from './theme-class-name.js';
+
+/**
+ * Tactile's `defineTheme` input, the Luke UI default: a teal accent, a neutral near-white light
+ * canvas, and a compact tactile material. Read it, copy it, or spread it into your own
+ * `defineTheme` call to start from Tactile.
+ */
+export const theme: ThemeInput = tactileTheme;
diff --git a/packages/@luke-ui/react/src/themes/tactile/theme-class-name.ts b/packages/@luke-ui/react/src/themes/tactile/theme-class-name.ts
new file mode 100644
index 00000000..a8fe6704
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/tactile/theme-class-name.ts
@@ -0,0 +1,11 @@
+/** Kept apart from `./index.ts` so the class never depends on the foundation. */
+
+import { getThemeClassName } from '../../theme/theme-class-name.js';
+
+const TACTILE_THEME_NAME = 'tactile';
+
+/**
+ * The Tactile theme's identity class, needed only when a document loads more than one theme
+ * stylesheet. Apply it to the theme root, such as `` or a subtree root.
+ */
+export const themeClassName = getThemeClassName(TACTILE_THEME_NAME);
diff --git a/packages/@luke-ui/react/src/themes/theme-bundle.test.ts b/packages/@luke-ui/react/src/themes/theme-bundle.test.ts
new file mode 100644
index 00000000..a00a8440
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/theme-bundle.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Bundles a one-line entry against the built `dist/themes//index.js`, so it measures what a
+ * consumer's bundler keeps. The identity class must not derive from `Theme.name`, which would
+ * make the foundation a prerequisite of the class string. A multi-layer CSS value must stay a
+ * concatenated literal, not `[...].join(', ')`, because a joined value survives dead-code
+ * elimination even when nothing reads it. Reads `dist`, so `build:packages` must run first.
+ */
+
+import { fileURLToPath } from 'node:url';
+import { build } from 'vite';
+import { describe, expect, it } from 'vite-plus/test';
+
+const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
+
+/** Four bundler runs in one file, well over the default per-test timeout. */
+const BUILD_TIMEOUT_MS = 120_000;
+
+const VIRTUAL_ENTRY = '\0luke-ui-theme-bundle-entry';
+
+/**
+ * Foundation data that must be absent from a class-only bundle and present in a `theme` bundle. The
+ * last marker is each theme's own authored accent, a hex value for Paper and an OKLCH one for
+ * Tactile.
+ */
+const FOUNDATION_MARKERS = {
+ paper: ['oklch(', 'radial-gradient(', '#185281'],
+ tactile: ['oklch(', 'radial-gradient(', 'oklch(0.75 0.1 200)'],
+} as const;
+
+const bundleCache = new Map>();
+
+/** Memoised so the four bundles below are built once each, however many tests read them. */
+function bundleThemeExport(
+ themeName: keyof typeof FOUNDATION_MARKERS,
+ exportName: 'theme' | 'themeClassName',
+): Promise {
+ const cacheKey = `${themeName}:${exportName}`;
+ const cached = bundleCache.get(cacheKey);
+ if (cached) return cached;
+
+ const pending = runThemeExportBuild(themeName, exportName);
+ bundleCache.set(cacheKey, pending);
+ return pending;
+}
+
+/** Minification stays off, so a marker cannot go missing through mangling. */
+async function runThemeExportBuild(
+ themeName: keyof typeof FOUNDATION_MARKERS,
+ exportName: 'theme' | 'themeClassName',
+): Promise {
+ const entrypoint = new URL(`../../dist/themes/${themeName}/index.js`, import.meta.url);
+ const source = `export { ${exportName} } from ${JSON.stringify(fileURLToPath(entrypoint))};`;
+ const result = await build({
+ build: {
+ lib: { entry: VIRTUAL_ENTRY, fileName: 'entry', formats: ['es'] },
+ minify: false,
+ rollupOptions: { input: VIRTUAL_ENTRY },
+ write: false,
+ },
+ configFile: false,
+ logLevel: 'silent',
+ plugins: [
+ {
+ load: (id) => (id === VIRTUAL_ENTRY ? source : null),
+ name: 'luke-ui-virtual-entry',
+ resolveId: (id) => (id === VIRTUAL_ENTRY ? id : null),
+ },
+ ],
+ root: packageRoot,
+ });
+ const outputs = (Array.isArray(result) ? result : [result]).flatMap((entry) => {
+ return 'output' in entry ? [entry.output] : [];
+ });
+ if (outputs.length === 0) throw new Error('expected a non-watching Vite build to emit output');
+ return outputs
+ .flat()
+ .flatMap((chunk) => (chunk.type === 'chunk' ? [chunk.code] : []))
+ .join('\n');
+}
+
+function byteLength(code: string): number {
+ return new TextEncoder().encode(code).byteLength;
+}
+
+for (const themeName of ['paper', 'tactile'] as const) {
+ describe(`${themeName} bundled theme`, () => {
+ const markers = FOUNDATION_MARKERS[themeName];
+
+ it(
+ 'leaves the foundation out of a bundle that imports only themeClassName',
+ async () => {
+ const code = await bundleThemeExport(themeName, 'themeClassName');
+
+ for (const marker of markers) {
+ expect(code).not.toContain(marker);
+ }
+ expect(code).toContain('luke-ui-theme-');
+ },
+ BUILD_TIMEOUT_MS,
+ );
+
+ // The positive control: the negative assertions above would also pass on an empty bundle.
+ it(
+ 'keeps the foundation in a bundle that imports theme',
+ async () => {
+ const code = await bundleThemeExport(themeName, 'theme');
+
+ for (const marker of markers) {
+ expect(code).toContain(marker);
+ }
+ },
+ BUILD_TIMEOUT_MS,
+ );
+
+ // A ratio ignores output growth that hits both bundles equally, and still catches retained
+ // foundation data that happens to contain none of the markers above.
+ it(
+ 'bundles the class alone at well under the weight of the whole theme',
+ async () => {
+ const [classOnly, whole] = await Promise.all([
+ bundleThemeExport(themeName, 'themeClassName'),
+ bundleThemeExport(themeName, 'theme'),
+ ]);
+
+ expect(byteLength(classOnly) / byteLength(whole)).toBeLessThan(0.5);
+ },
+ BUILD_TIMEOUT_MS,
+ );
+ });
+}
diff --git a/packages/@luke-ui/react/src/themes/themes.test.ts b/packages/@luke-ui/react/src/themes/themes.test.ts
new file mode 100644
index 00000000..b3028a83
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/themes.test.ts
@@ -0,0 +1,60 @@
+import { readFile } from 'node:fs/promises';
+import { describe, expect, it } from 'vite-plus/test';
+import packageJson from '../../package.json' with { type: 'json' };
+import { getThemeClassName } from '../theme/theme-class-name.js';
+import { theme as paperThemeInput, themeClassName as paperThemeClassName } from './paper/index.js';
+import {
+ theme as tactileThemeInput,
+ themeClassName as tactileThemeClassName,
+} from './tactile/index.js';
+
+const themeArtifacts = {
+ paper: new URL('../../dist/themes/paper/stylesheet.css', import.meta.url),
+ tactile: new URL('../../dist/themes/tactile/stylesheet.css', import.meta.url),
+} as const;
+
+const themeEntrypoints = {
+ paper: new URL('../../dist/themes/paper/index.js', import.meta.url),
+ tactile: new URL('../../dist/themes/tactile/index.js', import.meta.url),
+} as const;
+
+describe('bundled theme package exports', () => {
+ it('publishes a per-theme entrypoint and stylesheet, with no combined barrel', () => {
+ expect(packageJson.exports['./themes/tactile']).toBe('./dist/themes/tactile/index.js');
+ expect(packageJson.exports['./themes/paper']).toBe('./dist/themes/paper/index.js');
+ expect(packageJson.exports['./themes/tactile/stylesheet.css']).toBe(
+ './dist/themes/tactile/stylesheet.css',
+ );
+ expect(packageJson.exports['./themes/paper/stylesheet.css']).toBe(
+ './dist/themes/paper/stylesheet.css',
+ );
+ expect('./themes' in packageJson.exports).toBe(false);
+ });
+
+ // Each identity-class leaf holds its theme's name as a literal, so the class costs a consumer
+ // nothing but the string. That literal can drift from the foundation's own `name`.
+ it('derives each identity class from its own theme name', () => {
+ expect(paperThemeClassName).toBe(getThemeClassName(paperThemeInput.name));
+ expect(tactileThemeClassName).toBe(getThemeClassName(tactileThemeInput.name));
+ });
+
+ it('exports each generated stylesheet as an independent package entrypoint', async () => {
+ const paperCss = await readFile(themeArtifacts.paper, 'utf8');
+ const tactileCss = await readFile(themeArtifacts.tactile, 'utf8');
+
+ expect(paperCss).toContain(`.${paperThemeClassName} {`);
+ expect(paperCss).not.toContain(tactileThemeClassName);
+ expect(tactileCss).toContain(`.${tactileThemeClassName} {`);
+ expect(tactileCss).not.toContain(paperThemeClassName);
+ });
+
+ // Each per-theme entrypoint must import only its own foundation leaf, so a consumer of one theme
+ // never pulls the other into their bundle.
+ it('keeps each built theme entrypoint decoupled from the other theme', async () => {
+ const paperEntry = await readFile(themeEntrypoints.paper, 'utf8');
+ const tactileEntry = await readFile(themeEntrypoints.tactile, 'utf8');
+
+ expect(paperEntry).not.toContain('tactile');
+ expect(tactileEntry).not.toContain('paper');
+ });
+});
diff --git a/packages/@luke-ui/react/vite.config.ts b/packages/@luke-ui/react/vite.config.ts
index d0d73b69..57828534 100644
--- a/packages/@luke-ui/react/vite.config.ts
+++ b/packages/@luke-ui/react/vite.config.ts
@@ -13,8 +13,8 @@ const preservedDistFiles = new Set(['spritesheet.svg', 'docs', 'themes']);
const assetExports = [
'./stylesheet.css',
'./spritesheet.svg',
- './themes/tactile.css',
- './themes/paper.css',
+ './themes/tactile/stylesheet.css',
+ './themes/paper/stylesheet.css',
];
async function cleanDistExceptPreservedFiles() {
@@ -52,6 +52,7 @@ export default defineConfig({
dts: true,
entry: {
'*': ['src/*/index.tsx', 'src/*/index.ts', 'src/*/primitive/index.tsx'],
+ 'themes/*': ['src/themes/*/index.ts'],
},
exports: {
customExports: Object.fromEntries(