[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add visual theme matrix coverage (#111)

authored by

Luke Bennett and committed by
GitHub
(Jul 13, 2026, 11:48 AM +1000) 272da8ef bb31b6ca

+259 -11
+41
docs/VISUAL_TESTING.md
··· 49 49 work. 50 50 51 51 See [`TESTING.md`](./TESTING.md#visual-regression-tests) for how to write a visual test. 52 + 53 + ## Test every theme and mode 54 + 55 + Migrated components use the shared appearance matrix for Machined edge and ELMO in explicit light 56 + and dark modes. Pass each appearance to `renderVisual`, then capture it with 57 + `captureVisualAppearance`: 58 + 59 + ```tsx 60 + import { test } from 'vite-plus/test'; 61 + import { 62 + captureVisualAppearance, 63 + renderVisual, 64 + visualAppearances, 65 + } from '../test-utils/render-visual.js'; 66 + 67 + test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => { 68 + const scene = renderVisual(<Button>Continue</Button>, appearance); 69 + 70 + await captureVisualAppearance(scene, 'button/theme-matrix', appearance); 71 + }); 72 + ``` 73 + 74 + The helper appends the selected appearance to the literal base ID. The example creates these stable 75 + capture IDs: 76 + 77 + - `button/theme-matrix-machined-edge-light` 78 + - `button/theme-matrix-machined-edge-dark` 79 + - `button/theme-matrix-elmo-light` 80 + - `button/theme-matrix-elmo-dark` 81 + 82 + Use one literal base ID for the matrix. The visual runner expands it during duplicate-ID validation, 83 + so each look remains independently reviewable without repeating theme setup. Existing tests that 84 + call `renderVisual(node)` continue to render in Machined edge light. 85 + 86 + Theme identity and colour mode stay separate. To cover nested mode, put `data-color-mode="dark"` or 87 + `data-color-mode="light"` on a descendant inside the rendered scene. Do not add a nested theme 88 + identity because identity classes are not nestable. 89 + 90 + For a portalled surface, render the real component with the selected appearance, open it through 91 + `userEvent`, and capture the portal or `document.body`. The component carries the identity class and 92 + explicit colour mode from its trigger. Do not copy theme classes onto a test-only portal wrapper.
+33
packages/@luke-ui/react/scripts/visual-regression-lib.test.ts
··· 54 54 const owners = await validateCaptureIds(root); 55 55 expect([...owners.keys()]).toEqual(['button/multiline']); 56 56 }); 57 + 58 + test('expands appearance capture IDs and rejects collisions with explicit captures', async () => { 59 + const root = await mkdtemp(path.join(tmpdir(), 'visual-ids-')); 60 + await writeFile( 61 + path.join(root, 'matrix.visual.test.tsx'), 62 + "captureVisualAppearance(scene, 'button/tones', appearance)", 63 + ); 64 + await writeFile( 65 + path.join(root, 'single.visual.test.tsx'), 66 + "captureVisual(scene, 'button/tones-elmo-dark')", 67 + ); 68 + 69 + await expect(validateCaptureIds(root)).rejects.toThrow( 70 + 'Duplicate visual capture ID button/tones-elmo-dark', 71 + ); 72 + }); 73 + 74 + test('registers every independently named appearance capture', async () => { 75 + const root = await mkdtemp(path.join(tmpdir(), 'visual-ids-')); 76 + await writeFile( 77 + path.join(root, 'matrix.visual.test.tsx'), 78 + "captureVisualAppearance(scene, 'button/tones', appearance)", 79 + ); 80 + 81 + const owners = await validateCaptureIds(root); 82 + 83 + expect([...owners.keys()]).toEqual([ 84 + 'button/tones-machined-edge-light', 85 + 'button/tones-machined-edge-dark', 86 + 'button/tones-elmo-light', 87 + 'button/tones-elmo-dark', 88 + ]); 89 + });
+28 -7
packages/@luke-ui/react/scripts/visual-regression-lib.ts
··· 17 17 currentViewport?: string; 18 18 }; 19 19 20 + const appearanceSuffixes = [ 21 + 'machined-edge-light', 22 + 'machined-edge-dark', 23 + 'elmo-light', 24 + 'elmo-dark', 25 + ] as const; 26 + 20 27 type CaptureFile = { file: string; viewport?: string }; 21 28 22 29 export async function validateCaptureIds(sourceRoot: string) { ··· 33 40 if (calls.length !== matches.length) { 34 41 throw new Error(`Visual capture IDs must be string literals: ${file}`); 35 42 } 36 - for (const match of matches) { 37 - const id = match[1]; 38 - if (!id || !/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { 39 - throw new Error(`Visual capture ID must be namespaced: ${id}`); 43 + for (const match of matches) registerCaptureId(match[1], file, owners); 44 + 45 + const appearanceCalls = [...source.matchAll(/captureVisualAppearance\(/g)]; 46 + const appearanceMatches = [ 47 + ...source.matchAll(/captureVisualAppearance\([\s\S]*?,\s*'([^']+)'\s*,/g), 48 + ]; 49 + if (appearanceCalls.length !== appearanceMatches.length) { 50 + throw new Error(`Visual appearance capture IDs must be string literals: ${file}`); 51 + } 52 + for (const match of appearanceMatches) { 53 + for (const suffix of appearanceSuffixes) { 54 + registerCaptureId(`${match[1]}-${suffix}`, file, owners); 40 55 } 41 - const owner = owners.get(id); 42 - if (owner) throw new Error(`Duplicate visual capture ID ${id}: ${owner} and ${file}`); 43 - owners.set(id, file); 44 56 } 45 57 } 46 58 }), ··· 48 60 } 49 61 await visit(sourceRoot); 50 62 return owners; 63 + } 64 + 65 + function registerCaptureId(id: string | undefined, file: string, owners: Map<string, string>) { 66 + if (!id || !/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { 67 + throw new Error(`Visual capture ID must be namespaced: ${id}`); 68 + } 69 + const owner = owners.get(id); 70 + if (owner) throw new Error(`Duplicate visual capture ID ${id}: ${owner} and ${file}`); 71 + owners.set(id, file); 51 72 } 52 73 53 74 async function listPngs(root: string) {
+25
packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx
··· 1 1 import { expect, test } from 'vite-plus/test'; 2 2 import { page, userEvent } from 'vite-plus/test/context'; 3 3 import { captureVisual, renderVisual, Stack } from '../test-utils/render-visual.js'; 4 + import { elmoThemeClassName } from '../themes/index.js'; 4 5 import { ComboboxField } from './index.js'; 5 6 import { ComboboxItem } from './primitive/index.js'; 6 7 ··· 61 62 // alone. This captures the popover's alignment to the trigger and its layering, 62 63 // not just the listbox's own styling. 63 64 await captureVisual(page.elementLocator(document.body), 'combobox-field/open'); 65 + }); 66 + 67 + test('open menu carries the selected theme and mode through its portal', async () => { 68 + renderVisual( 69 + <Stack> 70 + <ComboboxField 71 + defaultItems={countryItems} 72 + label="Themed country" 73 + name="themed-country" 74 + placeholder="Select a country..." 75 + > 76 + {renderCountryItem} 77 + </ComboboxField> 78 + </Stack>, 79 + { mode: 'dark', theme: 'elmo' }, 80 + ); 81 + 82 + await userEvent.click(page.getByRole('combobox', { name: 'Themed country' })); 83 + const listbox = page.getByRole('listbox'); 84 + const portal = listbox.element().closest('[data-color-mode]'); 85 + 86 + expect(portal).toHaveClass(elmoThemeClassName); 87 + expect(portal).toHaveAttribute('data-color-mode', 'dark'); 88 + await captureVisual(page.elementLocator(document.body), 'combobox-field/open-elmo-dark'); 64 89 }); 65 90 66 91 test('mobile tray', async () => {
+62 -2
packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx
··· 1 - import type { CSSProperties } from 'react'; 2 - import { test } from 'vite-plus/test'; 1 + import type { CSSProperties, ReactNode } from 'react'; 2 + import { expect, test } from 'vite-plus/test'; 3 3 import { 4 + captureVisualAppearance, 4 5 captureVisual, 5 6 renderVisual, 6 7 Stack, 7 8 variantValuesFor, 9 + visualAppearances, 8 10 } from '../test-utils/render-visual.js'; 11 + import { vars } from '../theme/index.js'; 9 12 import { LoadingSpinner } from './index.js'; 10 13 11 14 const rowStyle = { ··· 43 46 44 47 await captureVisual(locator, 'loading-spinner/sizes-colors-modes'); 45 48 }); 49 + 50 + test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => { 51 + const oppositeMode = appearance.mode === 'light' ? 'dark' : 'light'; 52 + const scene = renderVisual( 53 + <div style={themeMatrixStyle}> 54 + <ThemeMatrixScope label="Root scope"> 55 + <LoadingSpinner aria-label="Root theme" style={spinnerStyle} value={65} /> 56 + </ThemeMatrixScope> 57 + <ThemeMatrixScope label="Opposite mode" mode={oppositeMode}> 58 + <LoadingSpinner aria-label="Opposite mode theme" style={spinnerStyle} value={65} /> 59 + </ThemeMatrixScope> 60 + </div>, 61 + appearance, 62 + ); 63 + 64 + await expect.element(scene).toHaveAttribute('data-color-mode', appearance.mode); 65 + await captureVisualAppearance(scene, 'loading-spinner/theme-matrix', appearance); 66 + }); 67 + 68 + const themeMatrixStyle = { 69 + backgroundColor: vars.color.surface.canvas, 70 + display: 'flex', 71 + gap: '1rem', 72 + padding: '1rem', 73 + } satisfies CSSProperties; 74 + 75 + const spinnerStyle = { 76 + color: vars.color.intent.accent.text, 77 + } satisfies CSSProperties; 78 + 79 + function ThemeMatrixScope({ 80 + children, 81 + label, 82 + mode, 83 + }: { 84 + children: ReactNode; 85 + label: string; 86 + mode?: 'light' | 'dark'; 87 + }) { 88 + return ( 89 + <div 90 + data-color-mode={mode} 91 + style={{ 92 + alignItems: 'center', 93 + backgroundColor: vars.color.surface.resting, 94 + border: `1px solid ${vars.color.border.decorative}`, 95 + color: vars.color.text.primary, 96 + display: 'flex', 97 + gap: '0.5rem', 98 + padding: '1rem', 99 + }} 100 + > 101 + {children} 102 + <span>{label}</span> 103 + </div> 104 + ); 105 + }
+36
packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx
··· 1 + import { expect, test } from 'vite-plus/test'; 2 + import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js'; 3 + import { cleanupVisual, renderVisual, visualAppearances } from './render-visual.js'; 4 + 5 + test('renders every bundled identity and explicit colour mode independently', () => { 6 + for (const appearance of visualAppearances) { 7 + const scene = renderVisual(<span>Theme contract</span>, appearance); 8 + const root = scene.element(); 9 + 10 + expect(root).toHaveClass( 11 + appearance.theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName, 12 + ); 13 + expect(root).toHaveAttribute('data-color-mode', appearance.mode); 14 + expect(getComputedStyle(root).colorScheme).toBe(appearance.mode); 15 + 16 + cleanupVisual(); 17 + } 18 + }); 19 + 20 + test('defaults existing callers to Machined edge light', () => { 21 + const root = renderVisual(<span>Default contract</span>).element(); 22 + 23 + expect(root).toHaveClass(machinedEdgeThemeClassName); 24 + expect(root).toHaveAttribute('data-color-mode', 'light'); 25 + }); 26 + 27 + test('allows a nested scope to select the opposite colour mode', () => { 28 + const scene = renderVisual(<div data-color-mode="light">Nested contract</div>, { 29 + mode: 'dark', 30 + theme: 'elmo', 31 + }); 32 + const nestedScope = scene.getByText('Nested contract').element(); 33 + 34 + expect(getComputedStyle(scene.element()).colorScheme).toBe('dark'); 35 + expect(getComputedStyle(nestedScope).colorScheme).toBe('light'); 36 + });
+34 -2
packages/@luke-ui/react/src/test-utils/render-visual.tsx
··· 2 2 3 3 // Loads the design-token stylesheet into the test document. 4 4 import '../stylesheet.css.js'; 5 + import '@luke-ui/react/themes/elmo.css'; 6 + import '@luke-ui/react/themes/machined-edge.css'; 5 7 import type { ComponentProps, ComponentType, CSSProperties, ReactNode } from 'react'; 6 8 import { act } from 'react'; 7 9 import type { Root } from 'react-dom/client'; ··· 14 16 import spritesheetHref from '../../dist/spritesheet.svg?url'; 15 17 import { IconSpritesheetProvider } from '../icon/index.js'; 16 18 import { themeRootClassName } from '../theme/index.js'; 19 + import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js'; 20 + import { cx } from '../utils/index.js'; 17 21 18 22 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; 19 23 20 24 const mounted: Array<{ container: HTMLElement; root: Root }> = []; 25 + 26 + export type VisualAppearance = { 27 + mode: 'light' | 'dark'; 28 + theme: 'machined-edge' | 'elmo'; 29 + }; 30 + 31 + export const visualAppearances = [ 32 + { mode: 'light', theme: 'machined-edge' }, 33 + { mode: 'dark', theme: 'machined-edge' }, 34 + { mode: 'light', theme: 'elmo' }, 35 + { mode: 'dark', theme: 'elmo' }, 36 + ] as const satisfies ReadonlyArray<VisualAppearance>; 37 + 38 + const defaultVisualAppearance: VisualAppearance = visualAppearances[0]; 21 39 22 40 /** 23 41 * Renders `node` inside the same theme root and icon spritesheet provider the 24 42 * app (and Storybook) wrap components with, then returns a Vitest locator for 25 43 * the mounted subtree ready to pass to `captureVisual`. 26 44 */ 27 - export function renderVisual(node: ReactNode) { 45 + export function renderVisual(node: ReactNode, appearance = defaultVisualAppearance) { 28 46 const container = document.body.appendChild(document.createElement('div')); 29 - container.className = themeRootClassName; 47 + container.className = cx(themeRootClassName, getThemeClassName(appearance.theme)); 48 + container.dataset.colorMode = appearance.mode; 30 49 const root = createRoot(container); 31 50 mounted.push({ container, root }); 32 51 ··· 37 56 return page.elementLocator(container); 38 57 } 39 58 59 + function getThemeClassName(theme: VisualAppearance['theme']) { 60 + return theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName; 61 + } 62 + 40 63 /** Captures a named scene into the revision output selected by the visual runner. */ 41 64 export async function captureVisual(locator: Locator, id: string) { 42 65 if (!/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { ··· 44 67 } 45 68 const viewport = `${window.innerWidth}x${window.innerHeight}`; 46 69 await expect.element(locator).toMatchScreenshot(`${id}__viewport-${viewport}`); 70 + } 71 + 72 + /** Captures one look with a stable identity-and-mode suffix added to `id`. */ 73 + export async function captureVisualAppearance( 74 + locator: Locator, 75 + id: string, 76 + appearance: VisualAppearance, 77 + ) { 78 + await captureVisual(locator, `${id}-${appearance.theme}-${appearance.mode}`); 47 79 } 48 80 49 81 /** Unmounts everything rendered by `renderVisual`. Registered globally in `visual-setup.ts`. */