diff --git a/docs/VISUAL_TESTING.md b/docs/VISUAL_TESTING.md
index 5a50255b..908206ae 100644
--- a/docs/VISUAL_TESTING.md
+++ b/docs/VISUAL_TESTING.md
@@ -49,3 +49,44 @@ tradeoff is that each comparison renders two revisions; the disposable base cach
work.
See [`TESTING.md`](./TESTING.md#visual-regression-tests) for how to write a visual test.
+
+## Test every theme and mode
+
+Migrated components use the shared appearance matrix for Machined edge and ELMO in explicit light
+and dark modes. Pass each appearance to `renderVisual`, then capture it with
+`captureVisualAppearance`:
+
+```tsx
+import { test } from 'vite-plus/test';
+import {
+ captureVisualAppearance,
+ renderVisual,
+ visualAppearances,
+} from '../test-utils/render-visual.js';
+
+test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => {
+ const scene = renderVisual(, appearance);
+
+ await captureVisualAppearance(scene, 'button/theme-matrix', appearance);
+});
+```
+
+The helper appends the selected appearance to the literal base ID. The example creates these stable
+capture IDs:
+
+- `button/theme-matrix-machined-edge-light`
+- `button/theme-matrix-machined-edge-dark`
+- `button/theme-matrix-elmo-light`
+- `button/theme-matrix-elmo-dark`
+
+Use one literal base ID for the matrix. The visual runner expands it during duplicate-ID validation,
+so each look remains independently reviewable without repeating theme setup. Existing tests that
+call `renderVisual(node)` continue to render in Machined edge light.
+
+Theme identity and colour mode stay separate. To cover nested mode, put `data-color-mode="dark"` or
+`data-color-mode="light"` on a descendant inside the rendered scene. Do not add a nested theme
+identity because identity classes are not nestable.
+
+For a portalled surface, render the real component with the selected appearance, open it through
+`userEvent`, and capture the portal or `document.body`. The component carries the identity class and
+explicit colour mode from its trigger. Do not copy theme classes onto a test-only portal wrapper.
diff --git a/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts b/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts
index f51fbece..08dbb34f 100644
--- a/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts
+++ b/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts
@@ -54,3 +54,36 @@ test('accepts multiline calls with trailing commas', async () => {
const owners = await validateCaptureIds(root);
expect([...owners.keys()]).toEqual(['button/multiline']);
});
+
+test('expands appearance capture IDs and rejects collisions with explicit captures', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'visual-ids-'));
+ await writeFile(
+ path.join(root, 'matrix.visual.test.tsx'),
+ "captureVisualAppearance(scene, 'button/tones', appearance)",
+ );
+ await writeFile(
+ path.join(root, 'single.visual.test.tsx'),
+ "captureVisual(scene, 'button/tones-elmo-dark')",
+ );
+
+ await expect(validateCaptureIds(root)).rejects.toThrow(
+ 'Duplicate visual capture ID button/tones-elmo-dark',
+ );
+});
+
+test('registers every independently named appearance capture', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'visual-ids-'));
+ await writeFile(
+ path.join(root, 'matrix.visual.test.tsx'),
+ "captureVisualAppearance(scene, 'button/tones', appearance)",
+ );
+
+ const owners = await validateCaptureIds(root);
+
+ expect([...owners.keys()]).toEqual([
+ 'button/tones-machined-edge-light',
+ 'button/tones-machined-edge-dark',
+ 'button/tones-elmo-light',
+ 'button/tones-elmo-dark',
+ ]);
+});
diff --git a/packages/@luke-ui/react/scripts/visual-regression-lib.ts b/packages/@luke-ui/react/scripts/visual-regression-lib.ts
index ac50f974..d6d818b1 100644
--- a/packages/@luke-ui/react/scripts/visual-regression-lib.ts
+++ b/packages/@luke-ui/react/scripts/visual-regression-lib.ts
@@ -17,6 +17,13 @@ export type VisualResult = {
currentViewport?: string;
};
+const appearanceSuffixes = [
+ 'machined-edge-light',
+ 'machined-edge-dark',
+ 'elmo-light',
+ 'elmo-dark',
+] as const;
+
type CaptureFile = { file: string; viewport?: string };
export async function validateCaptureIds(sourceRoot: string) {
@@ -33,14 +40,19 @@ export async function validateCaptureIds(sourceRoot: string) {
if (calls.length !== matches.length) {
throw new Error(`Visual capture IDs must be string literals: ${file}`);
}
- for (const match of matches) {
- const id = match[1];
- if (!id || !/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
- throw new Error(`Visual capture ID must be namespaced: ${id}`);
+ for (const match of matches) registerCaptureId(match[1], file, owners);
+
+ const appearanceCalls = [...source.matchAll(/captureVisualAppearance\(/g)];
+ const appearanceMatches = [
+ ...source.matchAll(/captureVisualAppearance\([\s\S]*?,\s*'([^']+)'\s*,/g),
+ ];
+ if (appearanceCalls.length !== appearanceMatches.length) {
+ throw new Error(`Visual appearance capture IDs must be string literals: ${file}`);
+ }
+ for (const match of appearanceMatches) {
+ for (const suffix of appearanceSuffixes) {
+ registerCaptureId(`${match[1]}-${suffix}`, file, owners);
}
- const owner = owners.get(id);
- if (owner) throw new Error(`Duplicate visual capture ID ${id}: ${owner} and ${file}`);
- owners.set(id, file);
}
}
}),
@@ -50,6 +62,15 @@ export async function validateCaptureIds(sourceRoot: string) {
return owners;
}
+function registerCaptureId(id: string | undefined, file: string, owners: Map) {
+ if (!id || !/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
+ throw new Error(`Visual capture ID must be namespaced: ${id}`);
+ }
+ const owner = owners.get(id);
+ if (owner) throw new Error(`Duplicate visual capture ID ${id}: ${owner} and ${file}`);
+ owners.set(id, file);
+}
+
async function listPngs(root: string) {
const result = new Map();
async function visit(directory: string) {
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 ee92c57d..fbaf02a2 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
@@ -1,6 +1,7 @@
import { expect, test } from 'vite-plus/test';
import { page, userEvent } from 'vite-plus/test/context';
import { captureVisual, renderVisual, Stack } from '../test-utils/render-visual.js';
+import { elmoThemeClassName } from '../themes/index.js';
import { ComboboxField } from './index.js';
import { ComboboxItem } from './primitive/index.js';
@@ -63,6 +64,30 @@ test('open menu', async () => {
await captureVisual(page.elementLocator(document.body), 'combobox-field/open');
});
+test('open menu carries the selected theme and mode through its portal', async () => {
+ renderVisual(
+
+
+ {renderCountryItem}
+
+ ,
+ { mode: 'dark', theme: 'elmo' },
+ );
+
+ 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(elmoThemeClassName);
+ expect(portal).toHaveAttribute('data-color-mode', 'dark');
+ await captureVisual(page.elementLocator(document.body), 'combobox-field/open-elmo-dark');
+});
+
test('mobile tray', async () => {
renderVisual(
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 c01f2534..26720b2e 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
@@ -1,11 +1,14 @@
-import type { CSSProperties } from 'react';
-import { test } from 'vite-plus/test';
+import type { CSSProperties, ReactNode } from 'react';
+import { expect, test } from 'vite-plus/test';
import {
+ captureVisualAppearance,
captureVisual,
renderVisual,
Stack,
variantValuesFor,
+ visualAppearances,
} from '../test-utils/render-visual.js';
+import { vars } from '../theme/index.js';
import { LoadingSpinner } from './index.js';
const rowStyle = {
@@ -43,3 +46,60 @@ test('sizes colors and modes', async () => {
await captureVisual(locator, 'loading-spinner/sizes-colors-modes');
});
+
+test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => {
+ const oppositeMode = appearance.mode === 'light' ? 'dark' : 'light';
+ const scene = renderVisual(
+
+
+
+
+
+
+
+
,
+ appearance,
+ );
+
+ await expect.element(scene).toHaveAttribute('data-color-mode', appearance.mode);
+ await captureVisualAppearance(scene, 'loading-spinner/theme-matrix', appearance);
+});
+
+const themeMatrixStyle = {
+ backgroundColor: vars.color.surface.canvas,
+ display: 'flex',
+ gap: '1rem',
+ padding: '1rem',
+} satisfies CSSProperties;
+
+const spinnerStyle = {
+ color: vars.color.intent.accent.text,
+} satisfies CSSProperties;
+
+function ThemeMatrixScope({
+ children,
+ label,
+ mode,
+}: {
+ children: ReactNode;
+ label: string;
+ mode?: 'light' | 'dark';
+}) {
+ return (
+
+ {children}
+ {label}
+
+ );
+}
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
new file mode 100644
index 00000000..c81c617e
--- /dev/null
+++ b/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx
@@ -0,0 +1,36 @@
+import { expect, test } from 'vite-plus/test';
+import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js';
+import { cleanupVisual, renderVisual, visualAppearances } from './render-visual.js';
+
+test('renders every bundled identity and explicit colour mode independently', () => {
+ for (const appearance of visualAppearances) {
+ const scene = renderVisual(Theme contract, appearance);
+ const root = scene.element();
+
+ expect(root).toHaveClass(
+ appearance.theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName,
+ );
+ expect(root).toHaveAttribute('data-color-mode', appearance.mode);
+ expect(getComputedStyle(root).colorScheme).toBe(appearance.mode);
+
+ cleanupVisual();
+ }
+});
+
+test('defaults existing callers to Machined edge light', () => {
+ const root = renderVisual(Default contract).element();
+
+ expect(root).toHaveClass(machinedEdgeThemeClassName);
+ expect(root).toHaveAttribute('data-color-mode', 'light');
+});
+
+test('allows a nested scope to select the opposite colour mode', () => {
+ const scene = renderVisual(Nested contract
, {
+ mode: 'dark',
+ theme: 'elmo',
+ });
+ const nestedScope = scene.getByText('Nested contract').element();
+
+ expect(getComputedStyle(scene.element()).colorScheme).toBe('dark');
+ expect(getComputedStyle(nestedScope).colorScheme).toBe('light');
+});
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 76819fb5..cd83a8bd 100644
--- a/packages/@luke-ui/react/src/test-utils/render-visual.tsx
+++ b/packages/@luke-ui/react/src/test-utils/render-visual.tsx
@@ -2,6 +2,8 @@
// Loads the design-token stylesheet into the test document.
import '../stylesheet.css.js';
+import '@luke-ui/react/themes/elmo.css';
+import '@luke-ui/react/themes/machined-edge.css';
import type { ComponentProps, ComponentType, CSSProperties, ReactNode } from 'react';
import { act } from 'react';
import type { Root } from 'react-dom/client';
@@ -14,19 +16,36 @@ import { page, userEvent } from 'vite-plus/test/context';
import spritesheetHref from '../../dist/spritesheet.svg?url';
import { IconSpritesheetProvider } from '../icon/index.js';
import { themeRootClassName } from '../theme/index.js';
+import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js';
+import { cx } from '../utils/index.js';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
+export type VisualAppearance = {
+ mode: 'light' | 'dark';
+ theme: 'machined-edge' | 'elmo';
+};
+
+export const visualAppearances = [
+ { mode: 'light', theme: 'machined-edge' },
+ { mode: 'dark', theme: 'machined-edge' },
+ { mode: 'light', theme: 'elmo' },
+ { mode: 'dark', theme: 'elmo' },
+] as const satisfies ReadonlyArray;
+
+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`.
*/
-export function renderVisual(node: ReactNode) {
+export function renderVisual(node: ReactNode, appearance = defaultVisualAppearance) {
const container = document.body.appendChild(document.createElement('div'));
- container.className = themeRootClassName;
+ container.className = cx(themeRootClassName, getThemeClassName(appearance.theme));
+ container.dataset.colorMode = appearance.mode;
const root = createRoot(container);
mounted.push({ container, root });
@@ -37,6 +56,10 @@ export function renderVisual(node: ReactNode) {
return page.elementLocator(container);
}
+function getThemeClassName(theme: VisualAppearance['theme']) {
+ return theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName;
+}
+
/** Captures a named scene into the revision output selected by the visual runner. */
export async function captureVisual(locator: Locator, id: string) {
if (!/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
@@ -46,6 +69,15 @@ export async function captureVisual(locator: Locator, id: string) {
await expect.element(locator).toMatchScreenshot(`${id}__viewport-${viewport}`);
}
+/** Captures one look with a stable identity-and-mode suffix added to `id`. */
+export async function captureVisualAppearance(
+ locator: Locator,
+ id: string,
+ appearance: VisualAppearance,
+) {
+ await captureVisual(locator, `${id}-${appearance.theme}-${appearance.mode}`);
+}
+
/** Unmounts everything rendered by `renderVisual`. Registered globally in `visual-setup.ts`. */
export function cleanupVisual() {
for (const { container, root } of mounted) {