diff --git a/apps/docs/content/docs/getting-started.mdx b/apps/docs/content/docs/getting-started.mdx index 116e5557..d229a26e 100644 --- a/apps/docs/content/docs/getting-started.mdx +++ b/apps/docs/content/docs/getting-started.mdx @@ -21,55 +21,19 @@ Inside this monorepo, use a workspace dependency instead. } ``` -## Render a component - -Apply the Luke UI theme class near the top of your app. Components use it to resolve design-token -CSS variables and scoped reset rules. - -```tsx -import lukeUiStyles from '@luke-ui/react/stylesheet.css?url'; -import { themeRootClassName } from '@luke-ui/react/theme'; -import { Text } from '@luke-ui/react/text'; - -export function App() { - return ( - <> - -
- Hello world -
- - ); -} -``` - -## Minimal example +## Choose a theme -For small demos, import the stylesheet and render a component directly. +Import the shared stylesheet and one bundled theme. Machined edge is the default tactile theme. ELMO +is the flatter alternative. -```tsx -import lukeUiStyles from '@luke-ui/react/stylesheet.css?url'; -import { Text } from '@luke-ui/react/text'; +See [Theming overview](/docs/theming) to compare the bundled themes and colour modes. -export function Example() { - return ( - <> - - Hello world - - ); -} -``` - -## Manual root class - -Use `themeRootClassName` when you need to attach the theme class yourself. +## Render a component -```tsx -import { themeRootClassName } from '@luke-ui/react/theme'; +Apply the Luke UI root class and the chosen theme identity class near the top of your app. +Components use them to resolve semantic CSS variables and scoped reset rules. -
; -``` + ## Next steps @@ -79,3 +43,4 @@ import { themeRootClassName } from '@luke-ui/react/theme'; - Read [Combobox Field](/docs/components/forms/combobox-field). - Read [Text](/docs/components/typography/text). - Read [Icon](/docs/components/visuals/icon). +- Read [Applying a theme](/docs/theming/applying). diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 5ba9c9a5..9e8c5ac8 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -1,4 +1,4 @@ { "title": "Documentation", - "pages": ["index", "getting-started", "components"] + "pages": ["index", "getting-started", "theming", "components"] } diff --git a/apps/docs/content/docs/theming/applying.mdx b/apps/docs/content/docs/theming/applying.mdx new file mode 100644 index 00000000..5716dc3f --- /dev/null +++ b/apps/docs/content/docs/theming/applying.mdx @@ -0,0 +1,52 @@ +--- +title: Applying a theme +description: Load Luke UI styles and apply theme identity and colour mode correctly. +--- + +## Load the CSS entrypoints + +Import the shared component stylesheet and exactly one theme stylesheet. Importing one bundled theme +does not pull in the other. + +Use `@luke-ui/react/themes/elmo.css` instead when ELMO is the application theme. + +## Apply theme identity + +Apply `themeRootClassName` and the matching identity class near the application root. + + + +The bundled identity constants are `machinedEdgeThemeClassName` and `elmoThemeClassName`. Theme +identity is not nestable. Choose one identity for an application or independent subtree. + +## Select a colour mode + +Set `data-color-mode="light"` or `data-color-mode="dark"` on the theme root to force a mode. Omit +the attribute to follow `prefers-color-scheme`. + +Colour-mode scopes are nestable. A nested scope can force the opposite mode without changing theme +identity. + + + +Every explicit scope sets the native `color-scheme` property. Browser controls and scrollbars +therefore use the same light or dark mode as Luke UI. + +## Portals + +Portalled content leaves its DOM ancestry, so copy the active identity class and explicit +`data-color-mode` value to the portal root. When the source follows the system preference, omit the +attribute on the portal root as well. Luke UI's Combobox copies these values automatically. +Application-owned portals must preserve the same public class-and-attribute contract. + +## Server rendering and the first paint + +Render the identity classes and any known explicit colour mode in the server HTML. When the mode is +system-controlled, omit `data-color-mode` so CSS can choose the correct mode before JavaScript runs. + +If a stored client preference can differ from the server default, run a small blocking bootstrap +script before the themed UI is painted. It should read the stored preference and set the root class +or `data-color-mode` before hydration. A React effect runs too late and paints the wrong mode first. +Theme libraries such as `next-themes` can provide this bootstrap when configured to write the same +attribute contract. Keep the server fallback and hydrated value consistent to avoid hydration +warnings. diff --git a/apps/docs/content/docs/theming/authoring.mdx b/apps/docs/content/docs/theming/authoring.mdx new file mode 100644 index 00000000..a6e84a6f --- /dev/null +++ b/apps/docs/content/docs/theming/authoring.mdx @@ -0,0 +1,55 @@ +--- +title: Authoring a theme +description: Compile a typed Luke UI foundation into a static theme stylesheet. +--- + +`buildTheme` compiles a typed foundation into the complete semantic token contract. The function is +pure, runs in Node, and produces static CSS containing the identity class and both colour modes. + +## Foundation fields + +A `ThemeFoundation` requires: + +- a kebab-case `name` +- independent `light` and `dark` mode foundations +- `color.neutral` and `color.accent` source colours for each mode +- final `actionControlFinish.recessed`, `resting`, and `raised` background images for each mode +- final `depth.recessed`, `resting`, `raised`, `floating`, and `overlay` box shadows for each mode + +Each mode may also provide `info`, `success`, `warning`, `danger`, and `focus` source colours. Luke +UI uses accessible mode-specific defaults when they are omitted. Shared optional fields select one +of the supported Capsize-compatible fonts and its four weight roles, plus the `detail`, `control`, +`surface`, and `overlay` radii. Applications must load files for non-system fonts. + + + + + +## Compile the stylesheet + +Define a `ThemeFoundation` in the application, then run the public `buildTheme` API in a build +script that writes its returned CSS to an application-owned stylesheet. Import that stylesheet +through the application's bundler or framework. A custom theme does not need to be added to +`@luke-ui/react` or wait for a Luke UI release. The two theme stylesheets included with Luke UI are +defaults, not a registry of supported themes. + + + +Apply `themeRootClassName` with `themeClassName('product')`. The framework-neutral example below +accepts its generated stylesheet URL explicitly. Select colour mode with the same `data-color-mode` +contract used by bundled themes. + + + +## Contrast validation + +Generation validates the sRGB-gamut-mapped result against WCAG 2.2 AA. Text pairs must reach 4.5:1. +Non-text UI pairs must reach 3:1. When a generated pair fails, `buildTheme` throws a +`ThemeContrastError`. Its message and `failures` array name every failing colour mode and token +pair, so the build should stop rather than publish an invalid stylesheet. diff --git a/apps/docs/content/docs/theming/index.mdx b/apps/docs/content/docs/theming/index.mdx new file mode 100644 index 00000000..0930238e --- /dev/null +++ b/apps/docs/content/docs/theming/index.mdx @@ -0,0 +1,31 @@ +--- +title: Theming overview +description: Choose a Luke UI theme identity and colour mode independently. +--- + +Luke UI separates **theme identity** from **colour mode**. Theme identity chooses the visual +language. Colour mode chooses light, dark, or the system preference. The two settings are +independent, so either bundled theme works in either mode. + +Luke UI includes two theme identities: + +- **Machined edge** is the default, compact tactile theme. +- **ELMO** is a flatter, lower-depth alternative. + +A theme defines the semantic colours, typography, radius, material, and depth foundations shared by +components. Luke UI defines component structure, states, and behaviour. Custom selectors, internal +palettes, theme-defined component variants, and component slots are not part of the supported +theming contract. Use public semantic CSS variables for local styling. + +Use the controls in the documentation header to preview any combination. Each choice is stored +separately and applies to hosted examples and the playground. Storybook provides separate theme +identity and colour-mode controls in its toolbar. + +Colour mode is nestable. A page can use one mode while a subtree forces the opposite mode. Apply one +theme identity at each independent themed root. Nested theme identities are not supported. + +## Next steps + +- [Apply a bundled theme](/docs/theming/applying). +- [Author a theme](/docs/theming/authoring). +- [Browse the semantic token reference](/docs/theming/token-reference). diff --git a/apps/docs/content/docs/theming/meta.json b/apps/docs/content/docs/theming/meta.json new file mode 100644 index 00000000..469ed4fb --- /dev/null +++ b/apps/docs/content/docs/theming/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Theming", + "pages": ["index", "applying", "authoring", "token-reference"] +} diff --git a/apps/docs/content/docs/theming/token-reference.mdx b/apps/docs/content/docs/theming/token-reference.mdx new file mode 100644 index 00000000..b164acb1 --- /dev/null +++ b/apps/docs/content/docs/theming/token-reference.mdx @@ -0,0 +1,28 @@ +--- +title: Token reference +description: Generated reference for Luke UI's public semantic CSS variables. +--- + +`vars` is the typed public semantic-token contract. Each typed path maps to one stable global CSS +custom property. Its segments become kebab case under the `--luke-` prefix. For example, +`vars.color.intent.danger.surface.solidHover` resolves to +`var(--luke-color-intent-danger-surface-solid-hover)`. + +The table below is generated from the public TypeScript declaration and its JSDoc rather than a +separate handwritten token list. + + + +## Custom styling + +Use a public semantic variable when a component API or layout utility is not the right fit. This is +the supported custom-styling boundary. Do not depend on private generated palette steps. + + diff --git a/apps/docs/package.json b/apps/docs/package.json index 29bd4376..6bed60e5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,8 +13,9 @@ "fix:format": "vp fmt . --write", "fix:lint": "vp lint . --type-aware --fix", "fix:unsafe": "vp lint . --type-aware --fix-dangerously", - "generate": "pnpm run routes:generate && pnpm run generate:playground", + "generate": "pnpm run routes:generate && pnpm run generate:token-reference && pnpm run generate:playground", "generate:playground": "node scripts/generate-playground-scope.ts && node scripts/generate-playground-types.ts && vp pack", + "generate:token-reference": "node scripts/generate-token-reference.ts", "postinstall": "fumadocs-mdx", "preview": "vp preview", "routes:generate": "tsr generate", diff --git a/apps/docs/scripts/generate-token-reference.ts b/apps/docs/scripts/generate-token-reference.ts new file mode 100644 index 00000000..00a9dd36 --- /dev/null +++ b/apps/docs/scripts/generate-token-reference.ts @@ -0,0 +1,57 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const contractPath = resolve(scriptDir, '../../../packages/@luke-ui/react/src/theme/contract.ts'); +const outputPath = resolve(scriptDir, '../src/generated/token-reference.generated.ts'); +const { flattenThemeContract } = (await import(contractPath)) as { + flattenThemeContract: () => Array<[path: string, variable: string]>; +}; + +export function generateTokenReference(): string { + const descriptions = readFamilyDescriptions(readFileSync(contractPath, 'utf8')); + const entries = flattenThemeContract().map(([path, variable]) => { + const family = path.split('.')[0]; + const description = family === undefined ? undefined : descriptions.get(family); + if (description === undefined) throw new Error(`Missing JSDoc for theme family "${family}"`); + return `\t/**\n\t * ${description}\n\t * CSS variable: \`${variable}\`.\n\t */\n\t'${path}': 'var(${variable})';`; + }); + + return `// Generated by scripts/generate-token-reference.ts — do not edit.\n\nexport interface ThemeTokenReference {\n${entries.join('\n')}\n}\n`; +} + +function readFamilyDescriptions(source: string): Map { + const sourceFile = ts.createSourceFile(contractPath, source, ts.ScriptTarget.Latest, true); + const declaration = sourceFile.statements.find( + (statement): statement is ts.VariableStatement => + ts.isVariableStatement(statement) && + statement.declarationList.declarations.some( + (item) => ts.isIdentifier(item.name) && item.name.text === 'themeContractTree', + ), + ); + const initializer = declaration?.declarationList.declarations.find( + (item) => ts.isIdentifier(item.name) && item.name.text === 'themeContractTree', + )?.initializer; + if (!initializer || !ts.isObjectLiteralExpression(initializer)) { + throw new Error('Could not find the themeContractTree object literal'); + } + + const descriptions = new Map(); + for (const property of initializer.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const name = property.name.getText(sourceFile).replaceAll("'", ''); + const jsDoc = ts.getJSDocCommentsAndTags(property).find(ts.isJSDoc); + if (typeof jsDoc?.comment === 'string') descriptions.set(name, jsDoc.comment); + } + return descriptions; +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const output = generateTokenReference(); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, output); + // oxlint-disable-next-line no-console + console.log(`generate-token-reference: wrote ${flattenThemeContract().length} token entries`); +} diff --git a/apps/docs/src/components/playground/color-mode-toggle.tsx b/apps/docs/src/components/playground/color-mode-toggle.tsx new file mode 100644 index 00000000..efc4c059 --- /dev/null +++ b/apps/docs/src/components/playground/color-mode-toggle.tsx @@ -0,0 +1,70 @@ +import { MonitorIcon, MoonIcon, SunIcon } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useSyncExternalStore } from 'react'; +import { IconToggleButtonGroup } from './icon-toggle-button-group.js'; + +const COLOR_MODES = [ + { Icon: SunIcon, label: 'Light theme', value: 'light' }, + { Icon: MoonIcon, label: 'Dark theme', value: 'dark' }, + { Icon: MonitorIcon, label: 'System theme', value: 'system' }, +] as const; + +type ColorMode = (typeof COLOR_MODES)[number]['value']; + +export function ColorModeToggle() { + const { setTheme } = useTheme(); + const colorMode = useHydratedColorModeSelection(); + + return ( + + ); +} + +export function useHydratedColorMode(): Exclude | null { + const { resolvedTheme } = useTheme(); + const isMounted = useIsMounted(); + + return isMounted && isColorMode(resolvedTheme) ? resolvedTheme : null; +} + +export function useHydratedColorModeSelection(): ColorMode | null { + const { theme } = useTheme(); + const isMounted = useIsMounted(); + + return isMounted && isColorModeSelection(theme) ? theme : null; +} + +function useIsMounted() { + const isMounted = useSyncExternalStore( + subscribeToHydration, + getHydratedSnapshot, + getServerSnapshot, + ); + + return isMounted; +} + +function isColorModeSelection(value: string | undefined): value is ColorMode { + return value === 'light' || value === 'dark' || value === 'system'; +} + +function isColorMode(value: string | undefined): value is Exclude { + return value === 'light' || value === 'dark'; +} + +function subscribeToHydration() { + return () => {}; +} + +function getHydratedSnapshot() { + return true; +} + +function getServerSnapshot() { + return false; +} diff --git a/apps/docs/src/components/playground/preview-runner.browser.test.tsx b/apps/docs/src/components/playground/preview-runner.browser.test.tsx new file mode 100644 index 00000000..ea9cf3ab --- /dev/null +++ b/apps/docs/src/components/playground/preview-runner.browser.test.tsx @@ -0,0 +1,51 @@ +import '../../styles/app.css'; +import '@luke-ui/react/themes/elmo.css'; +import '@luke-ui/react/themes/machined-edge.css'; +import { elmoThemeClassName } from '@luke-ui/react/themes'; +import { ThemeProvider } from 'next-themes'; +import { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { DocsThemeRoot } from '../theme-controls.js'; +import PreviewRunner from './preview-runner.js'; + +let container: HTMLElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + localStorage.clear(); + document.documentElement.removeAttribute('class'); + document.documentElement.removeAttribute('data-color-mode'); + container = undefined; + root = undefined; +}); + +test('applies appearance messages to the playground preview root', async () => { + container = document.body.appendChild(document.createElement('div')); + root = createRoot(container); + await act(async () => { + root?.render( + + + + + , + ); + }); + + await act(async () => { + window.postMessage( + { colorMode: 'dark', themeIdentity: 'elmo', type: 'playground:appearance' }, + window.location.origin, + ); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + const themeRoot = container.querySelector('[data-color-mode]'); + if (!themeRoot) throw new Error('Expected a playground theme root'); + await expect.poll(() => themeRoot.dataset.colorMode).toBe('dark'); + expect(themeRoot).toHaveClass(elmoThemeClassName); +}); diff --git a/apps/docs/src/components/playground/preview-runner.tsx b/apps/docs/src/components/playground/preview-runner.tsx index 178b73cb..0a234e69 100644 --- a/apps/docs/src/components/playground/preview-runner.tsx +++ b/apps/docs/src/components/playground/preview-runner.tsx @@ -1,3 +1,4 @@ +import { useTheme } from 'next-themes'; import type { ComponentType } from 'react'; import { useEffect, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -5,8 +6,9 @@ import { transform } from 'sucrase'; import { playgroundScope } from '../../generated/playground-scope.generated'; import { decodeCodeHash } from '../../lib/playground-hash'; import type { PlaygroundPreviewMessage } from '../../lib/playground-protocol'; -import { isPlaygroundCodeMessage } from '../../lib/playground-protocol'; +import { isPlaygroundParentMessage } from '../../lib/playground-protocol'; import { StoryWrapper } from '../../lib/story-wrapper'; +import { useDocsThemeIdentity } from '../theme-controls'; // Interop wrappers are cached so repeated requires return stable module objects. const moduleCache = new Map>(); @@ -15,6 +17,8 @@ type PreviewRun = { UserComponent: ComponentType; runId: number }; export default function PreviewRunner() { const [run, setRun] = useState(null); + const { setTheme: setColorMode } = useTheme(); + const { setThemeIdentity } = useDocsThemeIdentity(); useEffect(() => { let runId = 0; @@ -32,7 +36,13 @@ export default function PreviewRunner() { const onMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; - if (isPlaygroundCodeMessage(event.data)) runCode(event.data.code); + if (!isPlaygroundParentMessage(event.data)) return; + if (event.data.type === 'playground:code') { + runCode(event.data.code); + return; + } + setThemeIdentity(event.data.themeIdentity); + setColorMode(event.data.colorMode); }; window.addEventListener('message', onMessage); @@ -40,7 +50,7 @@ export default function PreviewRunner() { if (initialCode) runCode(initialCode); postToParent({ type: 'playground:ready' }); return () => window.removeEventListener('message', onMessage); - }, []); + }, [setColorMode, setThemeIdentity]); if (!run) return null; diff --git a/apps/docs/src/components/playground/theme-toggle.tsx b/apps/docs/src/components/playground/theme-toggle.tsx deleted file mode 100644 index 225b9655..00000000 --- a/apps/docs/src/components/playground/theme-toggle.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { MoonIcon, SunIcon } from 'lucide-react'; -import { useTheme } from 'next-themes'; -import { useSyncExternalStore } from 'react'; -import { IconToggleButtonGroup } from './icon-toggle-button-group'; - -const THEMES = [ - { Icon: SunIcon, label: 'Light theme', value: 'light' }, - { Icon: MoonIcon, label: 'Dark theme', value: 'dark' }, -] as const; - -type Theme = (typeof THEMES)[number]['value']; - -export function ThemeToggle() { - const { setTheme } = useTheme(); - const theme = useHydratedTheme(); - - return ; -} - -export function useHydratedTheme(): Theme | null { - const { resolvedTheme } = useTheme(); - const isMounted = useSyncExternalStore( - subscribeToHydration, - getHydratedSnapshot, - getServerSnapshot, - ); - - return isMounted && isTheme(resolvedTheme) ? resolvedTheme : null; -} - -function isTheme(value: string | undefined): value is Theme { - return value === 'light' || value === 'dark'; -} - -function subscribeToHydration() { - return () => {}; -} - -function getHydratedSnapshot() { - return true; -} - -function getServerSnapshot() { - return false; -} diff --git a/apps/docs/src/components/source-code-block.tsx b/apps/docs/src/components/source-code-block.tsx new file mode 100644 index 00000000..c30986b5 --- /dev/null +++ b/apps/docs/src/components/source-code-block.tsx @@ -0,0 +1,18 @@ +import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; + +const sources = import.meta.glob('../samples/*/*.tsx', { + eager: true, + import: 'default', + query: '?raw', +}); + +export interface SourceCodeBlockProps { + src: string; +} + +export function SourceCodeBlock({ src }: SourceCodeBlockProps) { + const source = sources[`../samples/${src}.tsx`]; + if (source === undefined) throw new Error(`Source example not found: ${src}`); + + return ; +} diff --git a/apps/docs/src/components/theme-controls.browser.test.tsx b/apps/docs/src/components/theme-controls.browser.test.tsx index a5ff151c..5352a5a8 100644 --- a/apps/docs/src/components/theme-controls.browser.test.tsx +++ b/apps/docs/src/components/theme-controls.browser.test.tsx @@ -1,42 +1,79 @@ import '../styles/app.css'; import '@luke-ui/react/themes/elmo.css'; import '@luke-ui/react/themes/machined-edge.css'; +import { elmoThemeClassName } from '@luke-ui/react/themes'; import { ThemeProvider } from 'next-themes'; import { act } from 'react'; -import type { ReactNode } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; import type { Root } from 'react-dom/client'; -import { createRoot, hydrateRoot } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; import { renderToString } from 'react-dom/server'; -import { afterEach, expect, test, vi } from 'vite-plus/test'; -import { page, userEvent } from 'vite-plus/test/context'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { cdp, page, userEvent } from 'vite-plus/test/context'; import { StoryWrapper } from '../lib/story-wrapper'; import { DocsThemeRoot, ThemeControls } from './theme-controls'; let container: HTMLElement | undefined; let root: Root | undefined; -afterEach(() => { +afterEach(async () => { if (root) act(() => root?.unmount()); container?.remove(); localStorage.clear(); document.documentElement.removeAttribute('class'); container = undefined; root = undefined; + await emulateColorScheme('light'); }); -test('exposes the playground colour-mode toggle beside the theme profile control', async () => { - renderTheme(); +test('persists theme identity and colour mode independently', async () => { + renderTheme( + <> + + + Theme example + + , + ); const profile = page.getByRole('combobox', { name: 'Theme profile' }); - const darkMode = getDarkModeButton(); + const darkMode = page.getByRole('radio', { name: 'Dark theme' }); + const themeRoot = getThemeRoot(); await userEvent.selectOptions(profile, 'elmo'); - await userEvent.click(darkMode, { force: true }); expect(profile).toHaveValue('elmo'); + expect(themeRoot).toHaveClass(elmoThemeClassName); + expect(themeRoot.dataset.colorMode).toBe('light'); + + await userEvent.click(darkMode, { force: true }); + + await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); + expect(themeRoot).toHaveClass(elmoThemeClassName); + + unmountTheme(); + renderTheme(); + + expect(page.getByRole('combobox', { name: 'Theme profile' })).toHaveValue('elmo'); + expect(page.getByRole('radio', { name: 'Dark theme' })).toBeChecked(); + expect(getThemeRoot()).toHaveClass(elmoThemeClassName); await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); }); +test('system colour mode follows the platform preference and drives the docs chrome', async () => { + await emulateColorScheme('dark'); + renderTheme(, { defaultTheme: 'system', enableSystem: true }); + + await userEvent.click(page.getByRole('radio', { name: 'System theme' }), { force: true }); + + await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); + expect(document.documentElement).toHaveClass('dark'); + + await emulateColorScheme('light'); + await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('light'); + expect(document.documentElement).toHaveClass('light'); +}); + test('bridges dark mode into the Luke UI root and example canvas', async () => { renderTheme( <> @@ -53,7 +90,7 @@ test('bridges dark mode into the Luke UI root and example canvas', async () => { if (!exampleCanvas) throw new Error('Expected an example canvas'); const lightBackground = getComputedStyle(exampleCanvas).backgroundColor; - await userEvent.click(getDarkModeButton(), { force: true }); + await userEvent.click(page.getByRole('radio', { name: 'Dark theme' }), { force: true }); await expect.poll(() => themeRoot.dataset.colorMode).toBe('dark'); expect(getComputedStyle(exampleCanvas).backgroundColor).not.toBe(lightBackground); @@ -68,7 +105,7 @@ test('keeps inherited docs shell text readable in dark mode', async () => { , ); - await userEvent.click(getDarkModeButton(), { force: true }); + await userEvent.click(page.getByRole('radio', { name: 'Dark theme' }), { force: true }); await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); const shellText = page.getByText('Unstyled shell text').element(); @@ -78,48 +115,53 @@ test('keeps inherited docs shell text readable in dark mode', async () => { expect(getComputedStyle(shellText).color).toBe(getComputedStyle(foregroundProbe).color); }); -test('omits the colour mode until hydration completes', async () => { +test('boots the stored colour mode before the themed root hydrates', async () => { localStorage.setItem('theme', 'dark'); - const tree = ( - - - Hydrated content - - + const bootstrap = renderToString( + + Server content + , + ); + const iframe = document.body.appendChild(document.createElement('iframe')); + iframe.srcdoc = `${bootstrap}`; + await new Promise((resolve) => + iframe.addEventListener('load', () => resolve(), { once: true }), ); - const serverMarkup = renderToString(tree); - expect(serverMarkup).not.toContain('data-color-mode'); - - container = document.body.appendChild(document.createElement('div')); - container.innerHTML = serverMarkup; - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - await act(async () => { - root = hydrateRoot(container as HTMLElement, tree); - }); - await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); - expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('hydration mismatch')); - consoleError.mockRestore(); + expect(iframe.contentDocument?.documentElement).toHaveAttribute('data-color-mode', 'dark'); + expect(iframe.contentDocument?.documentElement).toHaveAttribute('data-mode-at-hydration', 'dark'); + iframe.remove(); }); -function renderTheme(children: ReactNode) { +function renderTheme( + children: ReactNode, + options: Pick, 'defaultTheme' | 'enableSystem'> = {}, +) { container = document.body.appendChild(document.createElement('div')); root = createRoot(container); act(() => { root?.render( - + {children} , ); }); } -function getDarkModeButton() { - const button = container?.querySelector('button[aria-label="Dark theme"]'); - if (!button) throw new Error('Expected an accessible dark theme button'); - - return page.elementLocator(button); +function unmountTheme() { + act(() => root?.unmount()); + container?.remove(); + container = undefined; + root = undefined; } function getThemeRoot() { @@ -128,3 +170,9 @@ function getThemeRoot() { return themeRoot; } + +async function emulateColorScheme(mode: 'light' | 'dark') { + await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: mode }], + }); +} diff --git a/apps/docs/src/components/theme-controls.tsx b/apps/docs/src/components/theme-controls.tsx index 5117f7e5..36d2ed7a 100644 --- a/apps/docs/src/components/theme-controls.tsx +++ b/apps/docs/src/components/theme-controls.tsx @@ -2,46 +2,49 @@ import { themeRootClassName } from '@luke-ui/react/theme'; import { elmoThemeClassName, machinedEdgeThemeClassName } from '@luke-ui/react/themes'; import { cx } from '@luke-ui/react/utils'; import type { ChangeEvent, ComponentProps, PropsWithChildren } from 'react'; -import { createContext, useContext, useMemo, useState } from 'react'; -import { ThemeToggle, useHydratedTheme } from './playground/theme-toggle'; +import { createContext, useContext, useMemo, useSyncExternalStore } from 'react'; +import { ColorModeToggle, useHydratedColorMode } from './playground/color-mode-toggle.js'; -export type ThemeName = 'elmo' | 'machined-edge'; +export type ThemeIdentity = 'elmo' | 'machined-edge'; -interface ThemeSettings { - setTheme: (theme: ThemeName) => void; - theme: ThemeName; +const THEME_IDENTITY_STORAGE_KEY = 'luke-ui-docs-theme'; +const THEME_IDENTITY_CHANGE_EVENT = 'luke-ui-docs-theme-change'; + +interface ThemeIdentitySettings { + setThemeIdentity: (themeIdentity: ThemeIdentity) => void; + themeIdentity: ThemeIdentity; } -const ThemeSettingsContext = createContext(null); +const ThemeIdentitySettingsContext = createContext(null); export function DocsThemeRoot({ children }: PropsWithChildren) { - const colorMode = useHydratedTheme(); - const [theme, setTheme] = useState('machined-edge'); - const themeClassName = - theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName; - const settings = useMemo(() => ({ setTheme, theme }), [theme]); + const colorMode = useHydratedColorMode(); + const themeIdentity = useThemeIdentity(); + const themeIdentityClassName = + themeIdentity === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName; + const settings = useMemo(() => ({ setThemeIdentity, themeIdentity }), [themeIdentity]); return ( - +
{children}
-
+ ); } export function ThemeControls({ className, ...props }: ComponentProps<'div'>) { - const { setTheme, theme } = useThemeSettings(); + const { setThemeIdentity, themeIdentity } = useDocsThemeIdentity(); function handleThemeChange(event: ChangeEvent) { - setTheme(event.target.value === 'elmo' ? 'elmo' : 'machined-edge'); + setThemeIdentity(event.target.value === 'elmo' ? 'elmo' : 'machined-edge'); } return ( @@ -52,19 +55,49 @@ export function ThemeControls({ className, ...props }: ComponentProps<'div'>) { aria-label="Theme profile" className="h-8 rounded-md border border-fd-border bg-fd-background px-2 text-fd-foreground text-xs" onChange={handleThemeChange} - value={theme} + value={themeIdentity} > - +
); } -function useThemeSettings() { - const settings = useContext(ThemeSettingsContext); +export function useDocsThemeIdentity() { + const settings = useContext(ThemeIdentitySettingsContext); if (!settings) throw new Error('ThemeControls must be rendered inside DocsThemeRoot'); return settings; } + +function useThemeIdentity(): ThemeIdentity { + return useSyncExternalStore(subscribeToThemeIdentity, getThemeIdentity, getServerThemeIdentity); +} + +function subscribeToThemeIdentity(onStoreChange: () => void) { + const handleStorage = (event: StorageEvent) => { + if (event.key === THEME_IDENTITY_STORAGE_KEY) onStoreChange(); + }; + + window.addEventListener('storage', handleStorage); + window.addEventListener(THEME_IDENTITY_CHANGE_EVENT, onStoreChange); + return () => { + window.removeEventListener('storage', handleStorage); + window.removeEventListener(THEME_IDENTITY_CHANGE_EVENT, onStoreChange); + }; +} + +function getThemeIdentity(): ThemeIdentity { + return localStorage.getItem(THEME_IDENTITY_STORAGE_KEY) === 'elmo' ? 'elmo' : 'machined-edge'; +} + +function getServerThemeIdentity(): ThemeIdentity { + return 'machined-edge'; +} + +function setThemeIdentity(themeIdentity: ThemeIdentity) { + localStorage.setItem(THEME_IDENTITY_STORAGE_KEY, themeIdentity); + window.dispatchEvent(new Event(THEME_IDENTITY_CHANGE_EVENT)); +} diff --git a/apps/docs/src/examples/theming/semantic-variables.tsx b/apps/docs/src/examples/theming/semantic-variables.tsx new file mode 100644 index 00000000..de83a6e8 --- /dev/null +++ b/apps/docs/src/examples/theming/semantic-variables.tsx @@ -0,0 +1,27 @@ +import { vars } from '@luke-ui/react/theme'; +import type { PropsWithChildren } from 'react'; + +export default function SemanticVariablesExample() { + return ( + + This surface uses public semantic variables from the active theme and colour mode. + + ); +} + +function CustomSurface({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/docs/src/lib/playground-protocol.test.ts b/apps/docs/src/lib/playground-protocol.test.ts new file mode 100644 index 00000000..ede3ee1e --- /dev/null +++ b/apps/docs/src/lib/playground-protocol.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vite-plus/test'; +import { isPlaygroundParentMessage } from './playground-protocol.js'; + +test('accepts a complete playground appearance update', () => { + expect( + isPlaygroundParentMessage({ + colorMode: 'system', + themeIdentity: 'elmo', + type: 'playground:appearance', + }), + ).toBe(true); +}); + +test('rejects an unknown playground appearance', () => { + expect( + isPlaygroundParentMessage({ + colorMode: 'sepia', + themeIdentity: 'custom', + type: 'playground:appearance', + }), + ).toBe(false); +}); diff --git a/apps/docs/src/lib/playground-protocol.ts b/apps/docs/src/lib/playground-protocol.ts index 0cdeddfc..722cc903 100644 --- a/apps/docs/src/lib/playground-protocol.ts +++ b/apps/docs/src/lib/playground-protocol.ts @@ -5,6 +5,17 @@ const codeMessageSchema = z.object({ code: z.string(), }); +const appearanceMessageSchema = z.object({ + type: z.literal('playground:appearance'), + colorMode: z.enum(['light', 'dark', 'system']), + themeIdentity: z.enum(['machined-edge', 'elmo']), +}); + +const parentMessageSchema = z.discriminatedUnion('type', [ + codeMessageSchema, + appearanceMessageSchema, +]); + const previewMessageSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('playground:ready') }), z.object({ type: z.literal('playground:success') }), @@ -12,10 +23,12 @@ const previewMessageSchema = z.discriminatedUnion('type', [ ]); export type PlaygroundCodeMessage = z.infer; +export type PlaygroundAppearanceMessage = z.infer; +export type PlaygroundParentMessage = z.infer; export type PlaygroundPreviewMessage = z.infer; -export function isPlaygroundCodeMessage(data: unknown): data is PlaygroundCodeMessage { - return codeMessageSchema.safeParse(data).success; +export function isPlaygroundParentMessage(data: unknown): data is PlaygroundParentMessage { + return parentMessageSchema.safeParse(data).success; } export function isPlaygroundPreviewMessage(data: unknown): data is PlaygroundPreviewMessage { diff --git a/apps/docs/src/lib/token-reference-generator.test.ts b/apps/docs/src/lib/token-reference-generator.test.ts new file mode 100644 index 00000000..9d222447 --- /dev/null +++ b/apps/docs/src/lib/token-reference-generator.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vite-plus/test'; +import { generateTokenReference } from '../../scripts/generate-token-reference.js'; + +test('generates documented leaf token mappings from the public contract', () => { + const reference = generateTokenReference(); + + expect(reference).toContain("'color.surface.canvas': 'var(--luke-color-surface-canvas)';\n"); + expect(reference).toContain('Semantic colours for surfaces, content, borders, loading'); + expect(reference).toContain("'motion.easing.exit': 'var(--luke-motion-easing-exit)';\n"); + expect(reference).not.toContain('MapLeafNodes'); +}); diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx index f5f2e9fc..05bf49bd 100644 --- a/apps/docs/src/routes/__root.tsx +++ b/apps/docs/src/routes/__root.tsx @@ -52,7 +52,10 @@ function RootDocument({ children }: { children: ReactNode }) { - + {children} diff --git a/apps/docs/src/routes/docs/$.tsx b/apps/docs/src/routes/docs/$.tsx index 916f4d3f..395bf0d5 100644 --- a/apps/docs/src/routes/docs/$.tsx +++ b/apps/docs/src/routes/docs/$.tsx @@ -12,13 +12,20 @@ import * as z from 'zod'; import browserCollections from '../../../.source/browser'; import { ExampleBlock } from '../../components/example-block'; import { PageActions } from '../../components/page-actions'; +import { SourceCodeBlock } from '../../components/source-code-block'; import { baseOptions } from '../../lib/layout.shared'; import { source } from '../../lib/source'; import { getStorybookStoryUrl, withBasePath } from '../../lib/storybook'; const GITHUB_DOCS_URL = 'https://github.com/lukebennett88/luke-ui/blob/main/apps/docs/content/docs'; -const mdxComponents = { ...defaultMdxComponents, AutoTypeTable, ExampleBlock, TypeTable }; +const mdxComponents = { + ...defaultMdxComponents, + AutoTypeTable, + ExampleBlock, + SourceCodeBlock, + TypeTable, +}; export const Route = createFileRoute('/docs/$')({ component: Page, diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx index 582b33e9..5b18e4ad 100644 --- a/apps/docs/src/routes/playground/index.tsx +++ b/apps/docs/src/routes/playground/index.tsx @@ -5,18 +5,22 @@ import { Maximize2Icon, Minimize2Icon } from 'lucide-react'; import { lazy, Suspense, useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { Group, Panel, Separator } from 'react-resizable-panels'; import { useSpinDoctor } from 'spin-doctor'; +import { useHydratedColorModeSelection } from '../../components/playground/color-mode-toggle.js'; import { EditorSkeleton, EditorSkeletonShapeScript, LoadingPill, } from '../../components/playground/editor-skeleton'; -import { ThemeToggle } from '../../components/playground/theme-toggle'; import { useIsDesktop } from '../../components/playground/use-is-desktop'; import type { ViewportWidth } from '../../components/playground/viewport-toggle'; import { ViewportToggle } from '../../components/playground/viewport-toggle'; +import { ThemeControls, useDocsThemeIdentity } from '../../components/theme-controls'; import rawDefaultCode from '../../lib/playground-default-code.tsx?raw'; import { decodeCodeHash, encodeCodeHash } from '../../lib/playground-hash'; -import type { PlaygroundCodeMessage } from '../../lib/playground-protocol'; +import type { + PlaygroundAppearanceMessage, + PlaygroundCodeMessage, +} from '../../lib/playground-protocol'; import { isPlaygroundPreviewMessage } from '../../lib/playground-protocol'; import { withBasePath } from '../../lib/storybook'; @@ -32,6 +36,8 @@ export const Route = createFileRoute('/playground/')({ }); function Playground() { + const { themeIdentity } = useDocsThemeIdentity(); + const colorMode = useHydratedColorModeSelection(); const [initialCode] = useState(() => { if (typeof window === 'undefined') return rawDefaultCode; return decodeCodeHash(window.location.hash) ?? rawDefaultCode; @@ -57,6 +63,16 @@ function Playground() { }, [previewReadyRef], ); + const postAppearance = useCallback(() => { + const contentWindow = iframeRef.current?.contentWindow; + if (!contentWindow || colorMode === null) return; + const message: PlaygroundAppearanceMessage = { + colorMode, + themeIdentity, + type: 'playground:appearance', + }; + contentWindow.postMessage(message, window.location.origin); + }, [colorMode, themeIdentity]); useEffect(() => { const onMessage = (event: MessageEvent) => { @@ -68,6 +84,7 @@ function Playground() { previewReadyRef.current = true; markReady(); if (event.data.type === 'playground:ready') { + postAppearance(); postCode(codeRef.current); return; } @@ -94,7 +111,11 @@ function Playground() { // previewReadyRef, postCode, markError, markReady, and markSuccess are all // referentially stable (ref + useCallback/dispatch-based), so this still // only runs once per mount despite listing them. - }, [previewReadyRef, postCode, markError, markReady, markSuccess]); + }, [previewReadyRef, postAppearance, postCode, markError, markReady, markSuccess]); + + useEffect(() => { + postAppearance(); + }, [postAppearance]); useEffect(() => { if (!isPreviewFullscreen) return; @@ -118,7 +139,7 @@ function Playground() { return (
-
+
Luke UI Playground
-
- +
+
+ ); +} diff --git a/apps/docs/src/samples/theming/author-theme.tsx b/apps/docs/src/samples/theming/author-theme.tsx new file mode 100644 index 00000000..85532f50 --- /dev/null +++ b/apps/docs/src/samples/theming/author-theme.tsx @@ -0,0 +1,7 @@ +import { buildTheme } from '@luke-ui/react/theme'; +import type { ThemeFoundation } from '@luke-ui/react/theme'; +import { writeFile } from 'node:fs/promises'; + +export async function writeTheme(foundation: ThemeFoundation) { + await writeFile('src/product-theme.css', buildTheme(foundation)); +} diff --git a/apps/docs/src/samples/theming/custom-theme-app.tsx b/apps/docs/src/samples/theming/custom-theme-app.tsx new file mode 100644 index 00000000..b92d6925 --- /dev/null +++ b/apps/docs/src/samples/theming/custom-theme-app.tsx @@ -0,0 +1,14 @@ +import { themeClassName, themeRootClassName } from '@luke-ui/react/theme'; +import { cx } from '@luke-ui/react/utils'; +import type { PropsWithChildren } from 'react'; + +type AppProps = PropsWithChildren<{ themeStylesheetHref: string }>; + +export function App({ children, themeStylesheetHref }: AppProps) { + return ( + <> + +
{children}
+ + ); +} diff --git a/apps/docs/src/samples/theming/getting-started.tsx b/apps/docs/src/samples/theming/getting-started.tsx new file mode 100644 index 00000000..12c1a5af --- /dev/null +++ b/apps/docs/src/samples/theming/getting-started.tsx @@ -0,0 +1,16 @@ +import '@luke-ui/react/stylesheet.css'; +import '@luke-ui/react/themes/machined-edge.css'; +import { Text } from '@luke-ui/react/text'; +import { themeRootClassName } from '@luke-ui/react/theme'; +import { machinedEdgeThemeClassName } from '@luke-ui/react/themes'; +import { cx } from '@luke-ui/react/utils'; +import type { PropsWithChildren } from 'react'; + +export function App({ children }: PropsWithChildren) { + return ( +
+ Hello world + {children} +
+ ); +} diff --git a/apps/docs/src/samples/theming/nested-color-mode.tsx b/apps/docs/src/samples/theming/nested-color-mode.tsx new file mode 100644 index 00000000..dacb1ef6 --- /dev/null +++ b/apps/docs/src/samples/theming/nested-color-mode.tsx @@ -0,0 +1,10 @@ +import type { PropsWithChildren } from 'react'; + +export function DarkPageWithLightPreview({ children }: PropsWithChildren) { + return ( +
+ Dark application +
{children}
+
+ ); +} diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index a6d4c38d..3354d366 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -12,13 +12,27 @@ const fontStep = { * stable `--luke-*` custom property. */ export const themeContractTree = { + /** Semantic colours for surfaces, content, borders, loading, and six named intents. */ color: { - surface: { canvas: null, resting: null, recessed: null, floating: null, overlay: null }, + surface: { + canvas: null, + resting: null, + recessed: null, + floating: null, + overlay: null, + }, surfaceDisabled: null, loadingSkeleton: null, - text: { primary: null, secondary: null }, + text: { + primary: null, + secondary: null, + }, textDisabled: null, - border: { decorative: null, control: null, focus: null }, + border: { + decorative: null, + control: null, + focus: null, + }, borderDisabled: null, intent: { neutral: { @@ -100,22 +114,66 @@ export const themeContractTree = { }, }, }, - depth: { recessed: null, resting: null, raised: null, floating: null, overlay: null }, - actionControlFinish: { recessed: null, resting: null, raised: null }, + /** Composite box-shadow values for the shared depth ladder. */ + depth: { + recessed: null, + resting: null, + raised: null, + floating: null, + overlay: null, + }, + /** Final background images for the shared Button and IconButton face finish. */ + actionControlFinish: { + recessed: null, + resting: null, + raised: null, + }, + /** Composite type steps, font family, and theme-controlled weight roles. */ font: { - 100: { ...fontStep }, - 200: { ...fontStep }, - 300: { ...fontStep }, - 400: { ...fontStep }, - 500: { ...fontStep }, - 600: { ...fontStep }, - 700: { ...fontStep }, - 800: { ...fontStep }, - 900: { ...fontStep }, + 100: { + ...fontStep, + }, + 200: { + ...fontStep, + }, + 300: { + ...fontStep, + }, + 400: { + ...fontStep, + }, + 500: { + ...fontStep, + }, + 600: { + ...fontStep, + }, + 700: { + ...fontStep, + }, + 800: { + ...fontStep, + }, + 900: { + ...fontStep, + }, family: null, - weight: { body: null, label: null, heading: null, emphasis: null }, + weight: { + body: null, + label: null, + heading: null, + emphasis: null, + }, }, - radius: { detail: null, control: null, surface: null, overlay: null, full: null }, + /** Corner radii for details, controls, surfaces, overlays, and full rounding. */ + radius: { + detail: null, + control: null, + surface: null, + overlay: null, + full: null, + }, + /** The semantic spacing scale used by components and layout utilities. */ space: { 100: null, 200: null, @@ -127,11 +185,31 @@ export const themeContractTree = { 1200: null, 1600: null, }, - controlSize: { small: null, medium: null }, - iconSize: { xsmall: null, small: null, medium: null, large: null }, + /** Structural block sizes for small and medium controls. */ + controlSize: { + small: null, + medium: null, + }, + /** Inline and block sizes for the four public icon sizes. */ + iconSize: { + xsmall: null, + small: null, + medium: null, + large: null, + }, + /** Luke UI-owned durations and easing curves for interaction motion. */ motion: { - duration: { fast: null, medium: null, slow: null, ambient: null }, - easing: { standard: null, enter: null, exit: null }, + duration: { + fast: null, + medium: null, + slow: null, + ambient: null, + }, + easing: { + standard: null, + enter: null, + exit: null, + }, }, };