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(