[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.

Establish the tactile system through Button and IconButton (#112)

* Establish tactile Button and IconButton controls

* Center IconButton content

* Apply the default theme in docs

* Author semantic depth values directly

* Author semantic depth values directly

* Remove authored action control borders

* Remove inset control depth

* Restore exterior control depth

* Add action control face lighting

* Address action control review feedback

* Rename Button appearance API

* Clarify pending action cues

* Restore ELMO and docs theme controls

* Update tokens

authored by

Luke Bennett and committed by
GitHub
(Jul 13, 2026, 7:43 PM +1000) 5383bebd 272da8ef

+1156 -389
+7
.changeset/tactile-action-controls.md
··· 1 + --- 2 + '@luke-ui/react': minor 3 + --- 4 + 5 + Replace the Button and IconButton appearance API with semantic `tone` and `appearance` options, 6 + defaulting to neutral and solid, and expose the theme foundation contract used to author their depth 7 + and face finish.
+10
docs/STYLING.md
··· 33 33 font size, line height, and letter spacing so components cannot combine unrelated values. Icon sizes 34 34 carry forward the `xsmall`, `small`, `medium`, and `large` scale at 16px, 20px, 24px, and 32px. 35 35 36 + Each colour mode authors the final composite `box-shadow` for `depth.recessed`, `depth.resting`, 37 + `depth.raised`, `depth.floating`, and `depth.overlay`. Components select a semantic depth and do not 38 + branch on the theme identity. This keeps lower edges and exterior shadows visible in the foundation 39 + instead of deriving them from strength multipliers and hidden formulas. 40 + 41 + Each mode also authors final `background-image` values for `actionControlFinish.resting`, 42 + `actionControlFinish.raised`, and `actionControlFinish.recessed`. Button and IconButton layer this 43 + face lighting over their semantic surface colour. Ghost controls and forced-colours rendering do not 44 + use the authored finish. 45 + 36 46 Use `deriveConcentricRadius(innerRadius, gap)` for rounded elements nested inside another rounded 37 47 surface. It returns a CSS `calc()` value for the outer radius, so both inputs can be semantic theme 38 48 variables instead of theme-specific numbers.
+7
apps/docs/vitest.config.ts
··· 2 2 import { playwright } from 'vite-plus/test/browser-playwright'; 3 3 4 4 export default defineConfig({ 5 + optimizeDeps: { 6 + include: [ 7 + 'next-themes', 8 + 'react-aria-components/ToggleButton', 9 + 'react-aria-components/ToggleButtonGroup', 10 + ], 11 + }, 5 12 test: { 6 13 passWithNoTests: true, 7 14 projects: [
+111
apps/docs/src/components/theme-controls.browser.test.tsx
··· 1 + import '@luke-ui/react/stylesheet.css'; 2 + import '@luke-ui/react/themes/elmo.css'; 3 + import '@luke-ui/react/themes/machined-edge.css'; 4 + import { ThemeProvider } from 'next-themes'; 5 + import { act } from 'react'; 6 + import type { ReactNode } from 'react'; 7 + import type { Root } from 'react-dom/client'; 8 + import { createRoot, hydrateRoot } from 'react-dom/client'; 9 + import { renderToString } from 'react-dom/server'; 10 + import { afterEach, expect, test, vi } from 'vite-plus/test'; 11 + import { page, userEvent } from 'vite-plus/test/context'; 12 + import { StoryWrapper } from '../lib/story-wrapper'; 13 + import { DocsThemeRoot, ThemeControls } from './theme-controls'; 14 + 15 + let container: HTMLElement | undefined; 16 + let root: Root | undefined; 17 + 18 + afterEach(() => { 19 + if (root) act(() => root?.unmount()); 20 + container?.remove(); 21 + localStorage.clear(); 22 + document.documentElement.removeAttribute('class'); 23 + container = undefined; 24 + root = undefined; 25 + }); 26 + 27 + test('exposes the playground colour-mode toggle beside the theme profile control', async () => { 28 + renderTheme(<ThemeControls />); 29 + 30 + const profile = page.getByRole('combobox', { name: 'Theme profile' }); 31 + const darkMode = getDarkModeButton(); 32 + 33 + await userEvent.selectOptions(profile, 'elmo'); 34 + await userEvent.click(darkMode, { force: true }); 35 + 36 + expect(profile).toHaveValue('elmo'); 37 + await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); 38 + }); 39 + 40 + test('bridges dark mode into the Luke UI root and example canvas', async () => { 41 + renderTheme( 42 + <> 43 + <ThemeControls /> 44 + <StoryWrapper> 45 + <span>Example content</span> 46 + </StoryWrapper> 47 + </>, 48 + ); 49 + 50 + const themeRoot = getThemeRoot(); 51 + const exampleContent = page.getByText('Example content').element(); 52 + const exampleCanvas = exampleContent.parentElement; 53 + if (!exampleCanvas) throw new Error('Expected an example canvas'); 54 + 55 + const lightBackground = getComputedStyle(exampleCanvas).backgroundColor; 56 + await userEvent.click(getDarkModeButton(), { force: true }); 57 + 58 + await expect.poll(() => themeRoot.dataset.colorMode).toBe('dark'); 59 + expect(getComputedStyle(exampleCanvas).backgroundColor).not.toBe(lightBackground); 60 + }); 61 + 62 + test('omits the colour mode until hydration completes', async () => { 63 + localStorage.setItem('theme', 'dark'); 64 + const tree = ( 65 + <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}> 66 + <DocsThemeRoot> 67 + <span>Hydrated content</span> 68 + </DocsThemeRoot> 69 + </ThemeProvider> 70 + ); 71 + const serverMarkup = renderToString(tree); 72 + expect(serverMarkup).not.toContain('data-color-mode'); 73 + 74 + container = document.body.appendChild(document.createElement('div')); 75 + container.innerHTML = serverMarkup; 76 + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 77 + await act(async () => { 78 + root = hydrateRoot(container as HTMLElement, tree); 79 + }); 80 + 81 + await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); 82 + expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('hydration mismatch')); 83 + consoleError.mockRestore(); 84 + }); 85 + 86 + function renderTheme(children: ReactNode) { 87 + container = document.body.appendChild(document.createElement('div')); 88 + root = createRoot(container); 89 + 90 + act(() => { 91 + root?.render( 92 + <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> 93 + <DocsThemeRoot>{children}</DocsThemeRoot> 94 + </ThemeProvider>, 95 + ); 96 + }); 97 + } 98 + 99 + function getDarkModeButton() { 100 + const button = container?.querySelector<HTMLButtonElement>('button[aria-label="Dark theme"]'); 101 + if (!button) throw new Error('Expected an accessible dark theme button'); 102 + 103 + return page.elementLocator(button); 104 + } 105 + 106 + function getThemeRoot() { 107 + const themeRoot = container?.querySelector<HTMLElement>('[data-color-mode]'); 108 + if (!themeRoot) throw new Error('Expected a Luke UI theme root'); 109 + 110 + return themeRoot; 111 + }
+66
apps/docs/src/components/theme-controls.tsx
··· 1 + import { themeRootClassName } from '@luke-ui/react/theme'; 2 + import { elmoThemeClassName, machinedEdgeThemeClassName } from '@luke-ui/react/themes'; 3 + import { cx } from '@luke-ui/react/utils'; 4 + import type { ChangeEvent, ComponentProps, PropsWithChildren } from 'react'; 5 + import { createContext, useContext, useMemo, useState } from 'react'; 6 + import { ThemeToggle, useHydratedTheme } from './playground/theme-toggle'; 7 + 8 + export type ThemeName = 'elmo' | 'machined-edge'; 9 + 10 + interface ThemeSettings { 11 + setTheme: (theme: ThemeName) => void; 12 + theme: ThemeName; 13 + } 14 + 15 + const ThemeSettingsContext = createContext<ThemeSettings | null>(null); 16 + 17 + export function DocsThemeRoot({ children }: PropsWithChildren) { 18 + const colorMode = useHydratedTheme(); 19 + const [theme, setTheme] = useState<ThemeName>('machined-edge'); 20 + const themeClassName = 21 + theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName; 22 + const settings = useMemo(() => ({ setTheme, theme }), [theme]); 23 + 24 + return ( 25 + <ThemeSettingsContext.Provider value={settings}> 26 + <div 27 + className={cx(themeRootClassName, themeClassName, 'flex min-h-screen flex-1 flex-col')} 28 + data-color-mode={colorMode ?? undefined} 29 + > 30 + {children} 31 + </div> 32 + </ThemeSettingsContext.Provider> 33 + ); 34 + } 35 + 36 + export function ThemeControls({ className, ...props }: ComponentProps<'div'>) { 37 + const { setTheme, theme } = useThemeSettings(); 38 + 39 + function handleThemeChange(event: ChangeEvent<HTMLSelectElement>) { 40 + setTheme(event.target.value === 'elmo' ? 'elmo' : 'machined-edge'); 41 + } 42 + 43 + return ( 44 + <div {...props} className={cx('flex items-center gap-1', className)}> 45 + <label> 46 + <span className="sr-only">Theme profile</span> 47 + <select 48 + aria-label="Theme profile" 49 + className="h-8 rounded-md border border-fd-border bg-fd-background px-2 text-fd-foreground text-xs" 50 + onChange={handleThemeChange} 51 + value={theme} 52 + > 53 + <option value="machined-edge">Machined edge</option> 54 + <option value="elmo">ELMO</option> 55 + </select> 56 + </label> 57 + <ThemeToggle /> 58 + </div> 59 + ); 60 + } 61 + 62 + function useThemeSettings() { 63 + const settings = useContext(ThemeSettingsContext); 64 + if (!settings) throw new Error('ThemeControls must be rendered inside DocsThemeRoot'); 65 + return settings; 66 + }
+4
apps/docs/src/lib/layout.shared.tsx
··· 1 1 import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; 2 + import { ThemeControls } from '../components/theme-controls'; 2 3 import { getStorybookBaseUrl } from './storybook'; 3 4 4 5 export function baseOptions(): BaseLayoutProps { ··· 16 17 ], 17 18 nav: { 18 19 title: 'Luke UI', 20 + }, 21 + slots: { 22 + themeSwitch: ThemeControls, 19 23 }, 20 24 }; 21 25 }
+1 -1
apps/docs/src/lib/playground-default-code.tsx
··· 1 1 import { Button } from '@luke-ui/react/button'; 2 2 3 3 export default function Example() { 4 - return <Button tone="primary">Hello world</Button>; 4 + return <Button>Hello world</Button>; 5 5 }
+3 -3
apps/docs/src/lib/story-wrapper.tsx
··· 1 1 import { IconSpritesheetProvider } from '@luke-ui/react/icon'; 2 2 import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; 3 + import { vars } from '@luke-ui/react/theme'; 3 4 import type { ReactNode } from 'react'; 4 5 5 6 type StoryWrapperProps = { children: ReactNode }; ··· 9 10 <div 10 11 style={{ 11 12 alignItems: 'center', 12 - // Interim plain values; docs theming is rewired against the new theme contract in #96. 13 - backgroundColor: '#fff', 14 - color: '#212121', 13 + backgroundColor: vars.color.surface.canvas, 14 + color: vars.color.text.primary, 15 15 display: 'flex', 16 16 justifyContent: 'center', 17 17 minBlockSize: '6rem',
+7 -4
apps/docs/src/routes/__root.tsx
··· 1 - import { themeRootClassName } from '@luke-ui/react/theme'; 2 - import { cx } from '@luke-ui/react/utils'; 1 + import '@luke-ui/react/themes/elmo.css'; 2 + import '@luke-ui/react/themes/machined-edge.css'; 3 3 import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'; 4 4 import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; 5 5 import { RootProvider } from 'fumadocs-ui/provider/tanstack'; 6 6 import type { ReactNode } from 'react'; 7 7 import { lazy, Suspense } from 'react'; 8 + import { DocsThemeRoot } from '../components/theme-controls'; 8 9 import appCss from '../styles/app.css?url'; 9 10 10 11 const SearchDialog = lazy(() => import('../components/search')); ··· 50 51 <head> 51 52 <HeadContent /> 52 53 </head> 53 - <body className={cx(themeRootClassName, 'flex min-h-screen flex-col')}> 54 - <RootProvider search={{ SearchDialog: LazySearchDialog }}>{children}</RootProvider> 54 + <body className="flex min-h-screen flex-col"> 55 + <RootProvider search={{ SearchDialog: LazySearchDialog }}> 56 + <DocsThemeRoot>{children}</DocsThemeRoot> 57 + </RootProvider> 55 58 <Scripts /> 56 59 </body> 57 60 </html>
+44 -7
packages/@luke-ui/react/.storybook/preview.tsx
··· 1 1 /// <reference types="vite/client" /> 2 2 3 + import '../dist/themes/elmo.css'; 4 + import '../dist/themes/machined-edge.css'; 3 5 import '@luke-ui/react/stylesheet.css'; 4 6 import { IconSpritesheetProvider } from '@luke-ui/react/icon'; 5 7 import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; 6 8 import { themeRootClassName } from '@luke-ui/react/theme'; 9 + import { elmoThemeClassName, machinedEdgeThemeClassName } from '@luke-ui/react/themes'; 7 10 import addonA11y from '@storybook/addon-a11y'; 8 11 import addonDocs from '@storybook/addon-docs'; 9 12 import { definePreview } from '@storybook/react-vite'; ··· 11 14 export default definePreview({ 12 15 addons: [addonA11y(), addonDocs()], 13 16 decorators: [ 14 - (Story) => ( 15 - <IconSpritesheetProvider href={spriteSheetHref}> 16 - <div className={themeRootClassName}> 17 - <Story /> 18 - </div> 19 - </IconSpritesheetProvider> 20 - ), 17 + (Story, { globals }) => { 18 + const themeClassName = 19 + globals.theme === 'elmo' ? elmoThemeClassName : machinedEdgeThemeClassName; 20 + const colorMode = globals.colorMode === 'dark' ? 'dark' : 'light'; 21 + 22 + return ( 23 + <IconSpritesheetProvider href={spriteSheetHref}> 24 + <div className={`${themeRootClassName} ${themeClassName}`} data-color-mode={colorMode}> 25 + <Story /> 26 + </div> 27 + </IconSpritesheetProvider> 28 + ); 29 + }, 21 30 ], 31 + globalTypes: { 32 + colorMode: { 33 + description: 'Colour mode', 34 + toolbar: { 35 + dynamicTitle: true, 36 + icon: 'contrast', 37 + items: [ 38 + { title: 'Light', value: 'light' }, 39 + { title: 'Dark', value: 'dark' }, 40 + ], 41 + }, 42 + }, 43 + theme: { 44 + description: 'Luke UI theme', 45 + toolbar: { 46 + dynamicTitle: true, 47 + icon: 'paintbrush', 48 + items: [ 49 + { title: 'Machined edge', value: 'machined-edge' }, 50 + { title: 'ELMO', value: 'elmo' }, 51 + ], 52 + }, 53 + }, 54 + }, 55 + initialGlobals: { 56 + colorMode: 'light', 57 + theme: 'machined-edge', 58 + }, 22 59 parameters: { 23 60 a11y: { 24 61 // 'todo' - show a11y violations in the test UI only
+9 -4
apps/docs/src/components/playground/theme-toggle.tsx
··· 11 11 type Theme = (typeof THEMES)[number]['value']; 12 12 13 13 export function ThemeToggle() { 14 - const { resolvedTheme, setTheme } = useTheme(); 14 + const { setTheme } = useTheme(); 15 + const theme = useHydratedTheme(); 16 + 17 + return <IconToggleButtonGroup label="Theme" onChange={setTheme} options={THEMES} value={theme} />; 18 + } 19 + 20 + export function useHydratedTheme(): Theme | null { 21 + const { resolvedTheme } = useTheme(); 15 22 const isMounted = useSyncExternalStore( 16 23 subscribeToHydration, 17 24 getHydratedSnapshot, 18 25 getServerSnapshot, 19 26 ); 20 27 21 - const theme = isMounted && isTheme(resolvedTheme) ? resolvedTheme : null; 22 - 23 - return <IconToggleButtonGroup label="Theme" onChange={setTheme} options={THEMES} value={theme} />; 28 + return isMounted && isTheme(resolvedTheme) ? resolvedTheme : null; 24 29 } 25 30 26 31 function isTheme(value: string | undefined): value is Theme {
+16
apps/docs/src/examples/button/tone-and-appearance.tsx
··· 1 + import { Button } from '@luke-ui/react/button'; 2 + 3 + export default function ToneAndAppearance() { 4 + return ( 5 + <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '1rem' }}> 6 + <Button>Neutral solid</Button> 7 + <Button appearance="subtle" tone="neutral"> 8 + Neutral subtle 9 + </Button> 10 + <Button tone="danger">Danger solid</Button> 11 + <Button appearance="ghost" tone="accent"> 12 + Accent ghost 13 + </Button> 14 + </div> 15 + ); 16 + }
-12
apps/docs/src/examples/button/tones.tsx
··· 1 - import { Button } from '@luke-ui/react/button'; 2 - 3 - export default function Tones() { 4 - return ( 5 - <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '1rem' }}> 6 - <Button tone="primary">Primary</Button> 7 - <Button tone="critical">Critical</Button> 8 - <Button tone="ghost">Ghost</Button> 9 - <Button tone="neutral">Neutral</Button> 10 - </div> 11 - ); 12 - }
+10
apps/docs/src/examples/icon-button/tone-and-appearance.tsx
··· 1 + import { IconButton } from '@luke-ui/react/icon-button'; 2 + 3 + export default function ToneAndAppearance() { 4 + return ( 5 + <div style={{ display: 'flex', gap: '1rem' }}> 6 + <IconButton appearance="ghost" aria-label="Close" icon="close" tone="neutral" /> 7 + <IconButton aria-label="Delete" icon="delete" tone="danger" /> 8 + </div> 9 + ); 10 + }
-10
apps/docs/src/examples/icon-button/tones.tsx
··· 1 - import { IconButton } from '@luke-ui/react/icon-button'; 2 - 3 - export default function Tone() { 4 - return ( 5 - <div style={{ display: 'flex', gap: '1rem' }}> 6 - <IconButton aria-label="Close" icon="close" tone="ghost" /> 7 - <IconButton aria-label="Delete" icon="delete" tone="critical" /> 8 - </div> 9 - ); 10 - }
+46 -9
packages/@luke-ui/react/src/button/button.stories.tsx
··· 2 2 import { Button } from '@luke-ui/react/button'; 3 3 import { Icon } from '@luke-ui/react/icon'; 4 4 import type { CSSProperties } from 'react'; 5 + import { expect, fn, userEvent } from 'storybook/test'; 5 6 import preview from '../../.storybook/preview.js'; 6 7 import { vars } from '../theme.css.js'; 7 8 ··· 15 16 children: 'Button', 16 17 } satisfies Partial<ButtonProps>; 17 18 18 - const tones: Array<NonNullable<ButtonProps['tone']>> = ['primary', 'critical', 'ghost', 'neutral']; 19 + const tones: Array<NonNullable<ButtonProps['tone']>> = ['neutral', 'accent', 'danger']; 20 + const appearances: Array<NonNullable<ButtonProps['appearance']>> = ['solid', 'subtle', 'ghost']; 19 21 20 22 const sizes: Array<NonNullable<ButtonProps['size']>> = ['small', 'medium']; 21 23 ··· 51 53 } as const satisfies CSSProperties; 52 54 53 55 /** 54 - * Button tone communicates emphasis. Use `primary` for default actions and 55 - * `critical`, `ghost`, or `neutral` for alternate intent. 56 + * Tone communicates intent. Appearance controls the action's visual emphasis. 56 57 */ 57 - export const Tone = meta.story({ 58 + export const ToneAndAppearance = meta.story({ 58 59 args: baseArgs, 59 60 render: (props) => ( 60 - <div style={rowStyle}> 61 + <div style={stackStyle}> 61 62 {tones.map((tone) => ( 62 - <Button key={tone} tone={tone} {...props}> 63 - {tone} 64 - </Button> 63 + <div key={tone} style={rowStyle}> 64 + {appearances.map((appearance) => ( 65 + <Button appearance={appearance} key={appearance} tone={tone} {...props}> 66 + {tone} {appearance} 67 + </Button> 68 + ))} 69 + </div> 65 70 ))} 66 71 </div> 67 72 ), ··· 131 136 * pending states are rendered directly. 132 137 */ 133 138 export const States = meta.story({ 134 - args: baseArgs, 139 + args: { 140 + ...baseArgs, 141 + onPress: fn(), 142 + }, 135 143 render: (props) => ( 136 144 <div style={rowStyle}> 137 145 <Button {...props}>Default</Button> ··· 143 151 </Button> 144 152 </div> 145 153 ), 154 + play: async ({ args, canvas, step }) => { 155 + const pending = canvas.getByRole('button', { name: 'Pending' }); 156 + const disabled = canvas.getByRole('button', { name: 'Disabled' }); 157 + const busyCue = canvas.getByRole('progressbar', { hidden: true }); 158 + 159 + await step('pending remains focusable, busy, and non-interactive', async () => { 160 + await userEvent.tab(); 161 + await userEvent.tab(); 162 + await expect(pending).toHaveFocus(); 163 + await expect(pending).toHaveAttribute('aria-disabled', 'true'); 164 + await expect(getComputedStyle(busyCue).color).toBe(getComputedStyle(pending).outlineColor); 165 + await userEvent.click(pending); 166 + await expect(args.onPress).not.toHaveBeenCalled(); 167 + }); 168 + 169 + await step('pending uses the disabled visual treatment', async () => { 170 + await expect(getComputedStyle(pending).opacity).toBe('0.55'); 171 + await expect(getComputedStyle(pending).boxShadow).toBe(getComputedStyle(disabled).boxShadow); 172 + }); 173 + }, 146 174 }); 147 175 148 176 /** ··· 178 206 <Button {...props} /> 179 207 </div> 180 208 ), 209 + play: async ({ canvas }) => { 210 + const label = canvas.getByText( 211 + 'This a really really really really long string of text that should truncate instead of wrapping', 212 + ); 213 + 214 + await expect(getComputedStyle(label).textOverflow).toBe('ellipsis'); 215 + await expect(getComputedStyle(label).whiteSpace).toBe('nowrap'); 216 + await expect(label.scrollWidth).toBeGreaterThan(label.clientWidth); 217 + }, 181 218 });
+59 -26
packages/@luke-ui/react/src/button/button.visual.test.tsx
··· 1 - import { test } from 'vite-plus/test'; 2 - import { page } from 'vite-plus/test/context'; 1 + import { expect, test } from 'vite-plus/test'; 2 + import { page, userEvent } from 'vite-plus/test/context'; 3 3 import { Icon } from '../icon/index.js'; 4 4 import { 5 5 captureVisual, 6 + captureVisualAppearance, 6 7 focusViaKeyboard, 7 8 Grid, 8 9 renderVisual, 10 + visualAppearances, 9 11 variantValuesFor, 10 12 } from '../test-utils/render-visual.js'; 11 13 import { Button } from './index.js'; 12 14 13 - const tones = variantValuesFor<typeof Button, 'tone'>()([ 14 - 'primary', 15 - 'critical', 16 - 'ghost', 17 - 'neutral', 18 - ]); 15 + const tones = variantValuesFor<typeof Button, 'tone'>()(['neutral', 'accent', 'danger']); 16 + const appearances = variantValuesFor<typeof Button, 'appearance'>()(['solid', 'subtle', 'ghost']); 19 17 const sizes = variantValuesFor<typeof Button, 'size'>()(['small', 'medium']); 20 18 21 - test('tones across sizes', async () => { 19 + test('tones and appearances across sizes', async () => { 22 20 const locator = renderVisual( 23 - <Grid columns={tones.length}> 21 + <Grid columns={appearances.length}> 24 22 {sizes.flatMap((size) => { 25 - return tones.map((tone) => ( 26 - <Button key={`${size}-${tone}`} size={size} tone={tone}> 27 - {tone} 28 - </Button> 29 - )); 23 + return tones.flatMap((tone) => 24 + appearances.map((appearance) => ( 25 + <Button 26 + appearance={appearance} 27 + key={`${size}-${tone}-${appearance}`} 28 + size={size} 29 + tone={tone} 30 + > 31 + {tone} {appearance} 32 + </Button> 33 + )), 34 + ); 30 35 })} 31 36 </Grid>, 32 37 ); ··· 37 42 test('states: disabled, pending, with icons', async () => { 38 43 const locator = renderVisual( 39 44 <Grid columns={4}> 40 - <Button tone="primary">Default</Button> 41 - <Button isDisabled tone="primary"> 42 - Disabled 43 - </Button> 44 - <Button isPending tone="primary"> 45 - Pending 46 - </Button> 47 - <Button startIcon={<Icon name="add" />} tone="primary"> 48 - With icon 49 - </Button> 45 + <Button>Default</Button> 46 + <Button isDisabled>Disabled</Button> 47 + <Button isPending>Pending</Button> 48 + <Button startIcon={<Icon name="add" />}>With icon</Button> 50 49 </Grid>, 51 50 ); 52 51 ··· 56 55 test('keyboard focus ring', async () => { 57 56 const scene = renderVisual( 58 57 <Grid columns={1}> 59 - <Button tone="primary">Focus me</Button> 58 + <Button>Focus me</Button> 60 59 </Grid>, 61 60 ); 62 61 63 62 await focusViaKeyboard(page.getByRole('button', { name: 'Focus me' })); 64 63 await captureVisual(scene, 'button/focus-visible'); 64 + }); 65 + 66 + test.each(visualAppearances)('action states: $theme $mode', async (appearance) => { 67 + const scene = renderVisual( 68 + <Grid columns={5}> 69 + <Button>Resting</Button> 70 + <Button isDisabled>Disabled</Button> 71 + <Button isPending>Pending</Button> 72 + <Button appearance="subtle">Subtle</Button> 73 + <Button appearance="ghost">Ghost</Button> 74 + </Grid>, 75 + appearance, 76 + ); 77 + await expect 78 + .element(page.getByRole('button', { name: 'Pending' })) 79 + .toHaveAttribute('aria-disabled', 'true'); 80 + 81 + await captureVisualAppearance(scene, 'button/action-states', appearance); 82 + }); 83 + 84 + test.each(visualAppearances)('interactive states: $theme $mode', async (appearance) => { 85 + const scene = renderVisual(<Button>Action</Button>, appearance); 86 + const button = page.getByRole('button', { name: 'Action' }); 87 + await expect.element(button).toBeVisible(); 88 + 89 + await captureVisualAppearance(scene, 'button/resting', appearance); 90 + await userEvent.hover(button); 91 + await captureVisualAppearance(scene, 'button/hover', appearance); 92 + await userEvent.unhover(button); 93 + await focusViaKeyboard(button); 94 + await captureVisualAppearance(scene, 'button/focus-visible', appearance); 95 + await userEvent.keyboard('{Space>}'); 96 + await captureVisualAppearance(scene, 'button/pressed', appearance); 97 + await userEvent.keyboard('{/Space}'); 65 98 });
+14 -17
packages/@luke-ui/react/src/button/index.tsx
··· 2 2 import { LoadingSpinner } from '../loading-spinner/index.js'; 3 3 import * as styles from '../recipes/button-composed.css.js'; 4 4 import type * as primitiveStyles from '../recipes/button.css.js'; 5 - import { BUTTON_FONT_SIZE } from '../sizing/button-sizing.js'; 6 5 import { Text } from '../text/index.js'; 7 6 import type { DocumentedPressProps } from '../types/documented-rac-props.js'; 8 7 import type { ButtonProps as PrimitiveButtonProps } from './primitive/index.js'; 9 8 import { Button as PrimitiveButton } from './primitive/index.js'; 10 9 11 - interface ComposedButtonVariantProps extends NonNullable<styles.ButtonLabelVariants> {} 10 + interface ComposedButtonRecipeProps extends NonNullable<styles.ButtonLabelVariants> {} 12 11 13 - interface PrimitiveButtonVariantProps extends NonNullable<primitiveStyles.ButtonVariants> {} 12 + interface PrimitiveButtonRecipeProps extends NonNullable<primitiveStyles.ButtonVariants> {} 14 13 15 14 interface ButtonStyleProps { 15 + /** 16 + * Visual emphasis. 17 + * @default 'solid' 18 + */ 19 + appearance?: PrimitiveButtonRecipeProps['appearance']; 16 20 /** 17 21 * Icon shown after the label. 18 22 */ ··· 21 25 * Whether the button takes up the full inline size of its container. 22 26 * @default false 23 27 */ 24 - isBlock?: PrimitiveButtonVariantProps['isBlock']; 28 + isBlock?: PrimitiveButtonRecipeProps['isBlock']; 25 29 /** 26 30 * Shows pending button styles. When true, a spinner overlays the label. 27 31 * @default false 28 32 */ 29 - isPending?: ComposedButtonVariantProps['isPending']; 33 + isPending?: ComposedButtonRecipeProps['isPending']; 30 34 /** 31 35 * Sets the button size. 32 36 * @default 'medium' 33 37 */ 34 - size?: PrimitiveButtonVariantProps['size']; 38 + size?: PrimitiveButtonRecipeProps['size']; 35 39 /** 36 40 * Icon shown before the label. 37 41 */ 38 42 startIcon?: ReactNode; 39 43 /** 40 44 * Visual tone. Controls colour scheme. 41 - * @default 'primary' 45 + * @default 'neutral' 42 46 */ 43 - tone?: PrimitiveButtonVariantProps['tone']; 47 + tone?: PrimitiveButtonRecipeProps['tone']; 44 48 } 45 49 46 50 /** 47 - * Composed button with size, tone, pending, and block variants. 51 + * Composed button with size, tone, appearance, pending, and block options. 48 52 * 49 53 * @tier composed 50 54 */ ··· 69 73 )} 70 74 <span className={styles.buttonLabel({ isPending })}> 71 75 {startIcon} 72 - <Text 73 - color="inherit" 74 - fontSize={BUTTON_FONT_SIZE[size]} 75 - fontWeight="inherit" 76 - lineClamp={1} 77 - lineHeight="nospace" 78 - shouldDisableTrim 79 - > 76 + <Text color="inherit" elementType="span" lineClamp shouldInheritFont> 80 77 {typeof children === 'function' ? children(renderProps) : children} 81 78 </Text> 82 79 {endIcon}
+77 -2
packages/@luke-ui/react/src/icon-button/icon-button.stories.tsx
··· 2 2 import type { IconButtonProps } from '@luke-ui/react/icon-button'; 3 3 import { IconButton } from '@luke-ui/react/icon-button'; 4 4 import type { CSSProperties } from 'react'; 5 - import { expect } from 'storybook/test'; 5 + import { expect, fn, userEvent } from 'storybook/test'; 6 6 import preview from '../../.storybook/preview.js'; 7 7 8 8 const meta = preview.meta({ ··· 13 13 14 14 const baseArgs = { 15 15 icon: 'add', 16 + size: 'medium', 16 17 } satisfies Partial<IconButtonProps>; 17 18 18 19 const sizes: Array<NonNullable<IconButtonProps['size']>> = ['small', 'medium']; 20 + const tones: Array<NonNullable<IconButtonProps['tone']>> = ['neutral', 'accent', 'danger']; 21 + const appearances: Array<NonNullable<IconButtonProps['appearance']>> = ['solid', 'subtle', 'ghost']; 19 22 20 23 const flexWrapStyle = { 21 24 display: 'flex', ··· 26 29 export const Default = meta.story({ 27 30 args: { ...baseArgs, 'aria-label': 'Add' }, 28 31 play: async ({ canvas }) => { 29 - await expect(canvas.getByRole('button', { name: 'Add' })).toBeInTheDocument(); 32 + const button = canvas.getByRole('button', { name: 'Add' }); 33 + const icon = button.getElementsByTagName('svg').item(0); 34 + if (!icon) throw new Error('IconButton did not render an icon'); 35 + 36 + const buttonBounds = button.getBoundingClientRect(); 37 + const iconBounds = icon.getBoundingClientRect(); 38 + 39 + await expect(button).toBeInTheDocument(); 40 + await expect(getComputedStyle(button).blockSize).toBe('40px'); 41 + await expect(getComputedStyle(button).inlineSize).toBe('40px'); 42 + await expect(iconBounds.x + iconBounds.width / 2).toBeCloseTo( 43 + buttonBounds.x + buttonBounds.width / 2, 44 + ); 45 + await expect(iconBounds.y + iconBounds.height / 2).toBeCloseTo( 46 + buttonBounds.y + buttonBounds.height / 2, 47 + ); 48 + await userEvent.tab(); 49 + await expect(button).toHaveFocus(); 30 50 }, 51 + }); 52 + 53 + export const Appearance = meta.story({ 54 + args: { ...baseArgs, 'aria-label': 'Action' }, 55 + render: (props) => ( 56 + <div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(3, max-content)' }}> 57 + {tones.flatMap((tone) => { 58 + return appearances.map((appearance) => ( 59 + <IconButton 60 + {...props} 61 + appearance={appearance} 62 + aria-label={`${tone} ${appearance}`} 63 + key={`${tone}-${appearance}`} 64 + tone={tone} 65 + /> 66 + )); 67 + })} 68 + </div> 69 + ), 31 70 }); 32 71 33 72 export const Sizes = meta.story({ ··· 53 92 ))} 54 93 </div> 55 94 ), 95 + }); 96 + 97 + export const States = meta.story({ 98 + args: { 99 + ...baseArgs, 100 + 'aria-label': 'Action', 101 + onPress: fn(), 102 + }, 103 + render: (props) => ( 104 + <div style={flexWrapStyle}> 105 + <IconButton {...props} aria-label="Default" /> 106 + <IconButton {...props} aria-label="Disabled" isDisabled /> 107 + <IconButton {...props} aria-label="Pending" isPending /> 108 + </div> 109 + ), 110 + play: async ({ args, canvas, step }) => { 111 + const disabled = canvas.getByRole('button', { name: 'Disabled' }); 112 + const pending = canvas.getByRole('button', { name: 'Pending' }); 113 + 114 + await step('pending remains focusable, busy, and non-interactive', async () => { 115 + await userEvent.tab(); 116 + await userEvent.tab(); 117 + await expect(pending).toHaveFocus(); 118 + await expect(pending).toHaveAttribute('aria-disabled', 'true'); 119 + await expect(getComputedStyle(pending, '::after').borderTopColor).toBe( 120 + getComputedStyle(pending).outlineColor, 121 + ); 122 + await userEvent.click(pending); 123 + await expect(args.onPress).not.toHaveBeenCalled(); 124 + }); 125 + 126 + await step('pending uses the disabled visual treatment', async () => { 127 + await expect(getComputedStyle(pending).opacity).toBe('0.55'); 128 + await expect(getComputedStyle(pending).boxShadow).toBe(getComputedStyle(disabled).boxShadow); 129 + }); 130 + }, 56 131 }); 57 132 58 133 export const AllIcons = meta.story({
+38 -2
packages/@luke-ui/react/src/icon-button/icon-button.visual.test.tsx
··· 1 - import { test } from 'vite-plus/test'; 2 - import { page } from 'vite-plus/test/context'; 1 + import { expect, test } from 'vite-plus/test'; 2 + import { page, userEvent } from 'vite-plus/test/context'; 3 3 import { 4 4 captureVisual, 5 + captureVisualAppearance, 5 6 focusViaKeyboard, 6 7 Grid, 7 8 renderVisual, 9 + visualAppearances, 8 10 } from '../test-utils/render-visual.js'; 9 11 import { IconButton } from './index.js'; 10 12 ··· 19 21 ); 20 22 21 23 await captureVisual(locator, 'icon-button/sizes-states'); 24 + }); 25 + 26 + test.each(visualAppearances)('action states: $theme $mode', async (appearance) => { 27 + const scene = renderVisual( 28 + <Grid columns={5}> 29 + <IconButton aria-label="Resting" icon="add" /> 30 + <IconButton aria-label="Disabled" icon="delete" isDisabled /> 31 + <IconButton aria-label="Pending" icon="add" isPending /> 32 + <IconButton appearance="subtle" aria-label="Subtle" icon="add" /> 33 + <IconButton appearance="ghost" aria-label="Ghost" icon="add" /> 34 + </Grid>, 35 + appearance, 36 + ); 37 + await expect 38 + .element(page.getByRole('button', { name: 'Pending' })) 39 + .toHaveAttribute('aria-disabled', 'true'); 40 + 41 + await captureVisualAppearance(scene, 'icon-button/action-states', appearance); 42 + }); 43 + 44 + test.each(visualAppearances)('interactive states: $theme $mode', async (appearance) => { 45 + const scene = renderVisual(<IconButton aria-label="Action" icon="add" />, appearance); 46 + const button = page.getByRole('button', { name: 'Action' }); 47 + await expect.element(button).toBeVisible(); 48 + 49 + await captureVisualAppearance(scene, 'icon-button/resting', appearance); 50 + await userEvent.hover(button); 51 + await captureVisualAppearance(scene, 'icon-button/hover', appearance); 52 + await userEvent.unhover(button); 53 + await focusViaKeyboard(button); 54 + await captureVisualAppearance(scene, 'icon-button/focus-visible', appearance); 55 + await userEvent.keyboard('{Space>}'); 56 + await captureVisualAppearance(scene, 'icon-button/pressed', appearance); 57 + await userEvent.keyboard('{/Space}'); 22 58 }); 23 59 24 60 test('keyboard focus ring', async () => {
+5 -4
packages/@luke-ui/react/src/icon-button/index.tsx
··· 8 8 import type { DocumentedPressProps } from '../types/documented-rac-props.js'; 9 9 import { cx } from '../utils/index.js'; 10 10 11 - interface IconButtonVariantProps extends NonNullable<styles.IconButtonVariants> {} 11 + interface IconButtonRecipeProps extends NonNullable<styles.IconButtonVariants> {} 12 12 13 13 interface IconButtonStyleProps { 14 14 /** 15 15 * Sets the button size. 16 16 * @default 'medium' 17 17 */ 18 - size?: IconButtonVariantProps['size']; 18 + size?: IconButtonRecipeProps['size']; 19 19 } 20 20 21 21 /** ··· 34 34 35 35 /** Button that renders only an icon. */ 36 36 export function IconButton(props: IconButtonProps): JSX.Element { 37 - const { icon, size = 'medium', ...buttonProps } = props; 37 + const { icon, isPending = false, size = 'medium', ...buttonProps } = props; 38 38 39 39 return ( 40 40 <Button ··· 48 48 value, 49 49 ); 50 50 })} 51 + isPending={isPending} 51 52 size={size} 52 53 > 53 - <Icon aria-hidden name={icon} /> 54 + <Icon aria-hidden className={styles.icon({ isPending })} name={icon} /> 54 55 </Button> 55 56 ); 56 57 }
+7
packages/@luke-ui/react/src/recipes/button-composed.css.ts
··· 1 1 import type { RecipeVariants } from '@vanilla-extract/recipes'; 2 2 import { recipeInLayer } from '../styles/layered-style.css.js'; 3 3 import { vars } from '../styles/vars.css.js'; 4 + import { vars as themeVars } from '../theme/contract.css.js'; 4 5 5 6 export const buttonContent = recipeInLayer('recipes', { 6 7 base: { ··· 35 36 36 37 export const spinnerOverlay = recipeInLayer('recipes', { 37 38 base: { 39 + '@media': { 40 + '(forced-colors: active)': { 41 + color: 'ButtonText', 42 + }, 43 + }, 38 44 alignItems: 'center', 45 + color: themeVars.color.border.focus, 39 46 display: 'flex', 40 47 inset: 0, 41 48 justifyContent: 'center',
+109
packages/@luke-ui/react/src/recipes/button.browser.test.ts
··· 1 + import '@luke-ui/react/themes/machined-edge.css'; 2 + import { afterEach, expect, test } from 'vite-plus/test'; 3 + import { themeRootClassName } from '../theme/index.js'; 4 + import { machinedEdgeThemeClassName } from '../themes/index.js'; 5 + import { button } from './button.css.js'; 6 + 7 + let mounted: Array<HTMLElement> = []; 8 + 9 + afterEach(() => { 10 + for (const element of mounted) element.remove(); 11 + mounted = []; 12 + }); 13 + 14 + test('defaults to neutral solid with medium geometry', () => { 15 + const control = mountButton(); 16 + const neutralSolid = mountButton({ appearance: 'solid', tone: 'neutral' }); 17 + const accentSolid = mountButton({ appearance: 'solid', tone: 'accent' }); 18 + const style = getComputedStyle(control); 19 + const neutralSolidStyle = getComputedStyle(neutralSolid); 20 + 21 + expect(style.blockSize).toBe('40px'); 22 + expect(style.backgroundColor).toBe(neutralSolidStyle.backgroundColor); 23 + expect(style.backgroundImage).toBe(neutralSolidStyle.backgroundImage); 24 + expect(style.backgroundImage).not.toBe('none'); 25 + expect(style.backgroundColor).not.toBe(getComputedStyle(accentSolid).backgroundColor); 26 + expect(style.borderColor).toBe('rgba(0, 0, 0, 0)'); 27 + expect(style.borderWidth).toBe('1px'); 28 + expect(style.minBlockSize).toBe('24px'); 29 + expect(style.minInlineSize).toBe('24px'); 30 + expect(style.transform).toBe('matrix(1, 0, 0, 1, 0, 0)'); 31 + }); 32 + 33 + test('small and medium share the control geometry contract', () => { 34 + const small = mountButton({ size: 'small' }); 35 + const medium = mountButton({ size: 'medium' }); 36 + 37 + expect(getComputedStyle(small).blockSize).toBe('32px'); 38 + expect(getComputedStyle(medium).blockSize).toBe('40px'); 39 + }); 40 + 41 + test('hover raises and press recesses without scale', () => { 42 + const control = mountButton(); 43 + const restingFinish = getComputedStyle(control).backgroundImage; 44 + expect(restingFinish).not.toBe('none'); 45 + control.dataset.hovered = 'true'; 46 + const raisedFinish = getComputedStyle(control).backgroundImage; 47 + expect(raisedFinish).not.toBe(restingFinish); 48 + expect(getComputedStyle(control).transform).toBe('matrix(1, 0, 0, 1, 0, -1)'); 49 + 50 + delete control.dataset.hovered; 51 + control.dataset.pressed = 'true'; 52 + expect(getComputedStyle(control).backgroundImage).not.toBe(restingFinish); 53 + const pressedTransform = getComputedStyle(control).transform; 54 + expect(pressedTransform).toBe('matrix(1, 0, 0, 1, 0, 1)'); 55 + expect(pressedTransform.startsWith('matrix(1, 0, 0, 1')).toBe(true); 56 + }); 57 + 58 + test('disabled preserves resting material and ignores interaction', () => { 59 + const resting = mountButton(); 60 + const disabled = mountButton(); 61 + disabled.dataset.disabled = 'true'; 62 + disabled.dataset.hovered = 'true'; 63 + disabled.dataset.pressed = 'true'; 64 + 65 + expect(getComputedStyle(disabled).boxShadow).toBe(getComputedStyle(resting).boxShadow); 66 + expect(getComputedStyle(disabled).backgroundImage).toBe( 67 + getComputedStyle(resting).backgroundImage, 68 + ); 69 + expect(getComputedStyle(disabled).opacity).toBe('0.55'); 70 + expect(getComputedStyle(disabled).transform).toBe('matrix(1, 0, 0, 1, 0, 0)'); 71 + }); 72 + 73 + test('pending uses disabled-like resting material and ignores interaction styles', () => { 74 + const resting = mountButton(); 75 + const pending = mountButton(); 76 + pending.dataset.pending = 'true'; 77 + pending.dataset.hovered = 'true'; 78 + pending.dataset.pressed = 'true'; 79 + const ghost = mountButton({ appearance: 'ghost' }); 80 + 81 + expect(getComputedStyle(pending).backgroundImage).toBe(getComputedStyle(resting).backgroundImage); 82 + expect(getComputedStyle(pending).boxShadow).toBe(getComputedStyle(resting).boxShadow); 83 + expect(getComputedStyle(pending).opacity).toBe('0.55'); 84 + expect(getComputedStyle(pending).color).toBe(getComputedStyle(resting).color); 85 + expect(getComputedStyle(pending).transform).toBe('matrix(1, 0, 0, 1, 0, 0)'); 86 + expect(getComputedStyle(ghost).backgroundImage).toBe('none'); 87 + }); 88 + 89 + test('focus uses the independent semantic ring', () => { 90 + const control = mountButton(); 91 + control.dataset.focusVisible = 'true'; 92 + const style = getComputedStyle(control); 93 + 94 + expect(style.outlineStyle).toBe('solid'); 95 + expect(style.outlineWidth).toBe('2px'); 96 + expect(style.outlineOffset).toBe('2px'); 97 + expect(style.boxShadow).not.toBe('none'); 98 + }); 99 + 100 + function mountButton(options: Parameters<typeof button>[0] = {}) { 101 + const root = document.body.appendChild(document.createElement('div')); 102 + root.className = `${themeRootClassName} ${machinedEdgeThemeClassName}`; 103 + root.dataset.colorMode = 'light'; 104 + const control = root.appendChild(document.createElement('button')); 105 + control.className = button(options); 106 + control.style.transition = 'none'; 107 + mounted.push(root); 108 + return control; 109 + }
+182 -92
packages/@luke-ui/react/src/recipes/button.css.ts
··· 1 1 import type { RecipeVariants } from '@vanilla-extract/recipes'; 2 2 import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; 3 - import { vars } from '../styles/vars.css.js'; 3 + import { vars } from '../theme/contract.css.js'; 4 4 5 5 const base = styleInLayer('recipes', { 6 6 '@media': { 7 7 '(forced-colors: active)': { 8 + backgroundColor: 'ButtonFace', 9 + backgroundImage: 'none', 8 10 borderColor: 'ButtonText', 11 + boxShadow: 'none', 12 + color: 'ButtonText', 13 + forcedColorAdjust: 'auto', 9 14 selectors: { 10 - '&:disabled': { 15 + '&[data-focus-visible="true"]': { 16 + outlineColor: 'Highlight', 17 + }, 18 + '&[data-disabled="true"]': { 11 19 borderColor: 'GrayText', 12 20 color: 'GrayText', 21 + opacity: 1, 13 22 }, 14 - '&:enabled:hover': { 15 - backgroundColor: 'Highlight', 16 - borderColor: 'Highlight', 17 - color: 'HighlightText', 23 + '&[data-pending="true"]::after': { 24 + borderColor: 'ButtonText', 25 + borderInlineEndColor: 'transparent', 26 + }, 27 + '&[data-pending="true"]': { 28 + opacity: 1, 29 + }, 30 + }, 31 + transform: 'none', 32 + }, 33 + '(prefers-reduced-motion: reduce)': { 34 + selectors: { 35 + '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 36 + transform: 'none', 37 + }, 38 + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 39 + transform: 'none', 18 40 }, 19 41 }, 20 42 }, 21 43 }, 22 44 alignItems: 'center', 23 45 appearance: 'none', 24 - backgroundColor: vars.backgroundColor.default, 25 - borderColor: vars.border.default, 26 - borderRadius: vars.borderRadius.medium, 46 + borderColor: 'transparent', 47 + borderRadius: vars.radius.control, 27 48 borderStyle: 'solid', 28 - borderWidth: vars.borderWidth.thin, 49 + borderWidth: '1px', 50 + boxShadow: vars.depth.resting, 51 + boxSizing: 'border-box', 52 + cursor: 'pointer', 29 53 display: 'inline-flex', 30 - fontFamily: vars.font.family.body, 31 - fontWeight: vars.font.weight.medium, 32 - gap: vars.space.xsmall, 54 + fontFamily: vars.font.family, 55 + fontWeight: vars.font.weight.label, 56 + isolation: 'isolate', 33 57 justifyContent: 'center', 34 - lineHeight: vars.font.lineHeight.nospace, 35 - minInlineSize: 0, 36 - 37 - selectors: { 38 - '&:disabled': { 39 - backgroundColor: vars.backgroundColor.disabled, 40 - borderColor: vars.backgroundColor.disabled, 41 - color: vars.foregroundColor.disabled, 42 - }, 43 - '&:enabled:active': { 44 - scale: 0.98, 45 - }, 46 - }, 58 + letterSpacing: vars.font[200].letterSpacing, 59 + lineHeight: vars.font[200].lineHeight, 60 + minBlockSize: '24px', 61 + minInlineSize: '24px', 62 + outlineColor: 'transparent', 63 + outlineOffset: '2px', 64 + outlineStyle: 'solid', 65 + outlineWidth: '2px', 66 + position: 'relative', 47 67 textDecoration: 'none', 48 - transition: 49 - 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events', 68 + transform: 'translateY(0)', 50 69 transitionDuration: vars.motion.duration.fast, 70 + transitionProperty: 'background-color, border-color, box-shadow, color, opacity, transform', 51 71 transitionTimingFunction: vars.motion.easing.standard, 52 72 whiteSpace: 'nowrap', 73 + selectors: { 74 + '&[data-disabled="true"]': { 75 + cursor: 'not-allowed', 76 + opacity: 0.55, 77 + }, 78 + '&[data-focus-visible="true"]': { 79 + outlineColor: vars.color.border.focus, 80 + }, 81 + '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 82 + boxShadow: vars.depth.raised, 83 + transform: 'translateY(-1px)', 84 + }, 85 + '&[data-pending="true"]': { 86 + cursor: 'wait', 87 + opacity: 0.55, 88 + }, 89 + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 90 + boxShadow: vars.depth.recessed, 91 + transform: 'translateY(1px)', 92 + }, 93 + }, 53 94 }); 54 95 55 - /** Vanilla-extract recipe for the `Button` primitive's button styles. */ 96 + /** Semantic appearance and material recipe shared by Button and IconButton. */ 56 97 export const button = recipeInLayer('recipes', { 57 98 base, 58 99 defaultVariants: { 100 + appearance: 'solid', 59 101 isBlock: false, 60 102 size: 'medium', 61 - tone: 'primary', 103 + tone: 'neutral', 62 104 }, 63 105 variants: { 106 + appearance: { 107 + ghost: {}, 108 + solid: {}, 109 + subtle: {}, 110 + }, 64 111 isBlock: { 65 112 false: {}, 66 - true: { 67 - inlineSize: '100%', 68 - }, 113 + true: { inlineSize: '100%' }, 69 114 }, 70 115 size: { 71 116 medium: { 72 117 blockSize: vars.controlSize.medium, 73 - fontSize: vars.font.size.standard, 74 - paddingInline: vars.space.medium, 118 + fontSize: vars.font[200].fontSize, 119 + gap: vars.space[200], 120 + paddingInline: vars.space[400], 75 121 }, 76 122 small: { 77 123 blockSize: vars.controlSize.small, 78 - fontSize: vars.font.size.small, 79 - paddingInline: vars.space.small, 124 + fontSize: vars.font[100].fontSize, 125 + gap: vars.space[100], 126 + letterSpacing: vars.font[100].letterSpacing, 127 + lineHeight: vars.font[100].lineHeight, 128 + paddingInline: vars.space[300], 80 129 }, 81 130 }, 82 131 tone: { 83 - critical: { 84 - backgroundColor: vars.backgroundColor.criticalBold, 85 - borderColor: vars.backgroundColor.criticalBold, 86 - color: vars.themeColor.buttonColor, 132 + accent: {}, 133 + danger: {}, 134 + neutral: {}, 135 + }, 136 + }, 137 + compoundVariants: [ 138 + ...appearance( 139 + 'neutral', 140 + 'solid', 141 + vars.color.intent.neutral.surface, 142 + vars.color.intent.neutral.onSolid, 143 + ), 144 + ...appearance( 145 + 'accent', 146 + 'solid', 147 + vars.color.intent.accent.surface, 148 + vars.color.intent.accent.onSolid, 149 + ), 150 + ...appearance( 151 + 'danger', 152 + 'solid', 153 + vars.color.intent.danger.surface, 154 + vars.color.intent.danger.onSolid, 155 + ), 156 + ...appearance('neutral', 'subtle', vars.color.intent.neutral.surface, vars.color.text.primary), 157 + ...appearance( 158 + 'accent', 159 + 'subtle', 160 + vars.color.intent.accent.surface, 161 + vars.color.intent.accent.text, 162 + ), 163 + ...appearance( 164 + 'danger', 165 + 'subtle', 166 + vars.color.intent.danger.surface, 167 + vars.color.intent.danger.text, 168 + ), 169 + ghostAppearance('neutral', vars.color.text.primary), 170 + ghostAppearance('accent', vars.color.intent.accent.text), 171 + ghostAppearance('danger', vars.color.intent.danger.text), 172 + ], 173 + }); 174 + 175 + export type ButtonVariants = RecipeVariants<typeof button>; 176 + 177 + type Tone = 'neutral' | 'accent' | 'danger'; 178 + type Surface = { 179 + solid: string; 180 + solidHover: string; 181 + solidPressed: string; 182 + subtle: string; 183 + subtleHover: string; 184 + subtlePressed: string; 185 + }; 186 + 187 + function appearance(tone: Tone, appearance: 'solid' | 'subtle', surface: Surface, color: string) { 188 + const prefix = appearance === 'solid' ? 'solid' : 'subtle'; 189 + return [ 190 + { 191 + style: { 192 + '@media': { 193 + '(forced-colors: active)': { backgroundImage: 'none' }, 194 + }, 195 + backgroundColor: surface[prefix], 196 + backgroundImage: vars.actionControlFinish.resting, 197 + color, 87 198 selectors: { 88 - '&:enabled:active': { 89 - backgroundColor: vars.backgroundColor.criticalBoldPressed, 90 - borderColor: vars.backgroundColor.criticalBoldPressed, 199 + '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 200 + backgroundColor: surface[`${prefix}Hover`], 201 + backgroundImage: vars.actionControlFinish.raised, 91 202 }, 92 - '&:enabled:hover': { 93 - backgroundColor: vars.backgroundColor.criticalBoldHover, 94 - borderColor: vars.backgroundColor.criticalBoldHover, 203 + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 204 + backgroundColor: surface[`${prefix}Pressed`], 205 + backgroundImage: vars.actionControlFinish.recessed, 95 206 }, 96 207 }, 97 208 }, 98 - ghost: { 99 - backgroundColor: 'transparent', 100 - borderColor: 'transparent', 101 - color: vars.foregroundColor.neutralBold, 102 - selectors: { 103 - '&:enabled:active': { 104 - backgroundColor: vars.backgroundColor.neutralPressed, 105 - borderColor: vars.backgroundColor.neutralPressed, 106 - }, 107 - '&:enabled:hover': { 108 - backgroundColor: vars.backgroundColor.neutralHover, 109 - borderColor: vars.backgroundColor.neutralHover, 110 - }, 209 + variants: { appearance, tone }, 210 + }, 211 + ]; 212 + } 213 + 214 + function ghostAppearance(tone: Tone, color: string) { 215 + const surface = vars.color.intent[tone].surface; 216 + return { 217 + style: { 218 + backgroundColor: 'transparent', 219 + backgroundImage: 'none', 220 + borderColor: 'transparent', 221 + boxShadow: 'none', 222 + color, 223 + selectors: { 224 + '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 225 + backgroundColor: surface.subtleHover, 226 + boxShadow: vars.depth.raised, 111 227 }, 112 - }, 113 - neutral: { 114 - backgroundColor: vars.backgroundColor.default, 115 - borderColor: vars.border.default, 116 - color: vars.foregroundColor.primary, 117 - selectors: { 118 - '&:enabled:active': { 119 - backgroundColor: vars.backgroundColor.pressed, 120 - }, 121 - '&:enabled:hover': { 122 - backgroundColor: vars.backgroundColor.hover, 123 - }, 124 - }, 125 - }, 126 - primary: { 127 - backgroundColor: vars.themeColor.buttonBackgroundColor, 128 - borderColor: vars.themeColor.buttonBorderColor, 129 - color: vars.themeColor.buttonColor, 130 - selectors: { 131 - '&:enabled:active': { 132 - backgroundColor: vars.themeColor.buttonBackgroundColorActive, 133 - borderColor: vars.themeColor.buttonBorderColorActive, 134 - }, 135 - '&:enabled:hover': { 136 - backgroundColor: vars.themeColor.buttonBackgroundColorHover, 137 - borderColor: vars.themeColor.buttonBorderColorHover, 138 - }, 228 + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { 229 + backgroundColor: surface.subtlePressed, 230 + boxShadow: vars.depth.recessed, 139 231 }, 140 232 }, 141 233 }, 142 - }, 143 - }); 144 - 145 - /** Variant type for the `Button` recipe. */ 146 - export type ButtonVariants = RecipeVariants<typeof button>; 234 + variants: { appearance: 'ghost' as const, tone }, 235 + }; 236 + }
+34 -1
packages/@luke-ui/react/src/recipes/icon-button.css.ts
··· 1 1 import type { RecipeVariants } from '@vanilla-extract/recipes'; 2 2 import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; 3 - import { vars } from '../styles/vars.css.js'; 3 + import { vars } from '../theme/contract.css.js'; 4 4 5 5 export const iconButtonReset = styleInLayer('utilities', { 6 + '@media': { 7 + '(forced-colors: active)': { 8 + selectors: { 9 + '&[data-pending="true"]::after': { 10 + borderColor: 'ButtonText', 11 + borderInlineEndColor: 'transparent', 12 + }, 13 + }, 14 + }, 15 + }, 6 16 paddingInline: 0, 17 + selectors: { 18 + '&[data-pending="true"]::after': { 19 + borderColor: vars.color.border.focus, 20 + borderInlineEndColor: 'transparent', 21 + borderRadius: vars.radius.full, 22 + borderStyle: 'solid', 23 + borderWidth: '2px', 24 + blockSize: vars.iconSize.xsmall, 25 + content: '', 26 + inlineSize: vars.iconSize.xsmall, 27 + position: 'absolute', 28 + }, 29 + }, 30 + }); 31 + 32 + export const icon = recipeInLayer('recipes', { 33 + defaultVariants: { isPending: false }, 34 + variants: { 35 + isPending: { 36 + false: {}, 37 + true: { opacity: 0 }, 38 + }, 39 + }, 7 40 }); 8 41 9 42 /** Vanilla-extract recipe for the `IconButton` primitive's styles. */
+1 -7
packages/@luke-ui/react/src/sizing/button-sizing.ts
··· 1 - import type { FontSizeToken, IconSizeToken } from '../tokens/index.js'; 1 + import type { IconSizeToken } from '../tokens/index.js'; 2 2 3 3 /** Maps button size to the appropriate icon/spinner size. */ 4 4 export const BUTTON_ICON_SIZE: Record<'medium' | 'small', IconSizeToken> = { 5 5 medium: 'medium', 6 6 small: 'xsmall', 7 - }; 8 - 9 - /** Maps button size to the appropriate Text fontSize for composed button label. */ 10 - export const BUTTON_FONT_SIZE: Record<'medium' | 'small', FontSizeToken> = { 11 - medium: 'standard', 12 - small: 'small', 13 7 };
+1 -1
packages/@luke-ui/react/src/test-utils/render-visual.tsx
··· 103 103 * pattern. 104 104 * 105 105 * @example 106 - * const tones = variantValuesFor<typeof Button, 'tone'>()(['primary', 'critical', 'ghost', 'neutral']); 106 + * const tones = variantValuesFor<typeof Button, 'tone'>()(['neutral', 'accent', 'danger']); 107 107 */ 108 108 export function variantValuesFor< 109 109 Component extends ComponentType<any>,
+76 -3
packages/@luke-ui/react/src/theme/build-theme.test.ts
··· 13 13 import { elmoFoundation, machinedEdgeFoundation } from './foundations.js'; 14 14 15 15 const pairs = flattenThemeContract(); 16 - const isModePath = (path: string) => path.startsWith('color.') || path.startsWith('depth.'); 16 + const isModePath = (path: string) => 17 + path.startsWith('actionControlFinish.') || path.startsWith('color.') || path.startsWith('depth.'); 17 18 const modeVarNames = pairs.filter(([path]) => isModePath(path)).map(([, varName]) => varName); 18 19 const identityVarNames = pairs.filter(([path]) => !isModePath(path)).map(([, varName]) => varName); 19 20 ··· 114 115 expect(css).toContain('--luke-color-surface-disabled'); 115 116 expect(css).toContain('--luke-color-intent-accent-text-hover'); 116 117 expect(css).toContain('--luke-depth-raised'); 118 + expect(css).toContain('--luke-action-control-finish-resting'); 117 119 expect(css).toContain('--luke-space-100:'); 118 120 expect(css).toContain('--luke-control-size-small'); 119 121 expect(css).toContain('--luke-motion-easing-standard'); ··· 123 125 expect(css).toContain('--luke-font-900-letter-spacing: -0.025em'); 124 126 expect(css).toContain('--luke-icon-size-xsmall: 16px'); 125 127 expect(css).toContain('--luke-icon-size-large: 32px'); 128 + }); 129 + 130 + it('emits the authored semantic depth values without synthesising material layers', () => { 131 + expect(extractValue(blocks.baseLight, '--luke-depth-resting')).toBe( 132 + machinedEdgeFoundation.light.depth.resting, 133 + ); 134 + expect(extractValue(blocks.mediaDark, '--luke-depth-raised')).toBe( 135 + machinedEdgeFoundation.dark.depth.raised, 136 + ); 137 + for (const foundation of [machinedEdgeFoundation, elmoFoundation]) { 138 + for (const mode of ['light', 'dark'] as const) { 139 + expect(foundation[mode].depth.resting).not.toContain('inset'); 140 + expect(foundation[mode].depth.raised).not.toContain('inset'); 141 + expect(foundation[mode].depth.resting.split(', ')).toHaveLength(2); 142 + expect(foundation[mode].depth.raised.split(', ')).toHaveLength(2); 143 + expect(foundation[mode].depth.recessed).toBe('none'); 144 + } 145 + } 146 + }); 147 + 148 + it('keeps ELMO softer than Machined edge while retaining finish and state depth', () => { 149 + const elmoBlocks = splitBlocks(buildTheme(elmoFoundation)); 150 + for (const [elmoBlock, machinedBlock] of [ 151 + [elmoBlocks.baseLight, blocks.baseLight], 152 + [elmoBlocks.mediaDark, blocks.mediaDark], 153 + ] as const) { 154 + const elmoResting = extractValue(elmoBlock, '--luke-depth-resting'); 155 + const elmoRaised = extractValue(elmoBlock, '--luke-depth-raised'); 156 + const elmoFinish = extractValue(elmoBlock, '--luke-action-control-finish-resting'); 157 + 158 + expect(extractValue(machinedBlock, '--luke-depth-resting')).toContain('0 2px 0'); 159 + expect(elmoResting).not.toContain('0 2px 0'); 160 + expect(elmoRaised).not.toContain('0 3px 0'); 161 + expect(elmoResting.split(', ')).toHaveLength(2); 162 + expect(elmoRaised.split(', ')).toHaveLength(2); 163 + expect(elmoRaised).not.toBe(elmoResting); 164 + expect(elmoFinish).toContain('radial-gradient'); 165 + expect(elmoFinish).not.toBe( 166 + extractValue(machinedBlock, '--luke-action-control-finish-resting'), 167 + ); 168 + } 126 169 }); 127 170 }); 128 171 ··· 144 187 it('fills omitted optional fields with the documented defaults', () => { 145 188 const explicitFoundation: ThemeFoundation = { 146 189 dark: { 190 + actionControlFinish: minimalFoundation.dark.actionControlFinish, 147 191 color: { ...minimalFoundation.dark.color, ...defaultSourceColors.dark }, 148 - material: minimalFoundation.dark.material, 192 + depth: minimalFoundation.dark.depth, 149 193 }, 150 194 light: { 195 + actionControlFinish: minimalFoundation.light.actionControlFinish, 151 196 color: { ...minimalFoundation.light.color, ...defaultSourceColors.light }, 152 - material: minimalFoundation.light.material, 197 + depth: minimalFoundation.light.depth, 153 198 }, 154 199 name: 'minimal-check', 155 200 radius: { ...defaultRadius }, ··· 161 206 expect(css).toContain(`${varName}: `); 162 207 } 163 208 expect(css).toContain('--luke-color-border-focus: oklch('); 209 + }); 210 + }); 211 + 212 + describe('buildTheme foundation validation', () => { 213 + it('rejects empty or stylesheet-breaking depth values', () => { 214 + const emptyDepth: ThemeFoundation = { 215 + ...machinedEdgeFoundation, 216 + light: { 217 + ...machinedEdgeFoundation.light, 218 + depth: { ...machinedEdgeFoundation.light.depth, resting: ' ' }, 219 + }, 220 + name: 'empty-depth', 221 + }; 222 + const unsafeDepth: ThemeFoundation = { 223 + ...machinedEdgeFoundation, 224 + dark: { 225 + ...machinedEdgeFoundation.dark, 226 + depth: { ...machinedEdgeFoundation.dark.depth, overlay: 'none; color: red' }, 227 + }, 228 + name: 'unsafe-depth', 229 + }; 230 + 231 + expect(() => buildTheme(emptyDepth)).toThrow( 232 + 'light.depth.resting: must be a non-empty CSS box-shadow value', 233 + ); 234 + expect(() => buildTheme(unsafeDepth)).toThrow( 235 + 'dark.depth.overlay: must be a non-empty CSS box-shadow value', 236 + ); 164 237 }); 165 238 }); 166 239
+20 -108
packages/@luke-ui/react/src/theme/build-theme.ts
··· 1 1 import type { Oklch } from './color.js'; 2 2 import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js'; 3 3 import { flattenThemeContract } from './contract.js'; 4 - import type { 5 - ThemeFoundation, 6 - ThemeMaterialProfile, 7 - ThemeModeFoundation, 8 - ThemeSourceColors, 9 - } from './foundation.js'; 4 + import type { ThemeFoundation, ThemeModeFoundation, ThemeSourceColors } from './foundation.js'; 10 5 import { 11 6 defaultFontFamily, 12 7 defaultFontWeights, ··· 205 200 for (const [path, color] of Object.entries(colors)) { 206 201 values[path] = formatOklch(color); 207 202 } 208 - Object.assign(values, buildDepthValues(modeFoundation.material)); 203 + for (const [name, value] of Object.entries(modeFoundation.depth)) { 204 + values[`depth.${name}`] = value; 205 + } 206 + for (const [name, value] of Object.entries(modeFoundation.actionControlFinish)) { 207 + values[`actionControlFinish.${name}`] = value; 208 + } 209 209 return { failures, values }; 210 210 } 211 211 ··· 533 533 return failures; 534 534 } 535 535 536 - function buildDepthValues(material: ThemeMaterialProfile): Record<string, string> { 537 - const shadowColor = gamutMapOklch(parseColor(material.shadowColor)); 538 - const white: Oklch = { c: 0, h: 0, l: 1 }; 539 - const blurScale = material.blur === 'sharp' ? 0.75 : 1.5; 540 - 541 - const highlight = shadowLayer({ 542 - alpha: material.highlightStrength, 543 - color: white, 544 - inset: true, 545 - offsetY: 1, 546 - }); 547 - const ring = shadowLayer({ 548 - alpha: material.edgeStrength, 549 - color: shadowColor, 550 - inset: true, 551 - spread: 1, 552 - }); 553 - const lowerEdge = 554 - material.lowerEdgeDepth > 0 555 - ? shadowLayer({ 556 - alpha: material.edgeStrength, 557 - color: shadowColor, 558 - inset: true, 559 - offsetY: -material.lowerEdgeDepth, 560 - }) 561 - : null; 562 - const exterior = (offsetY: number, blur: number, alphaBase: number) => 563 - shadowLayer({ 564 - alpha: alphaBase * material.shadowStrength, 565 - blur: blur * blurScale, 566 - color: shadowColor, 567 - offsetY, 568 - }); 569 - const innerTopShadow = shadowLayer({ 570 - alpha: 0.5 * material.shadowStrength, 571 - blur: 2 * blurScale, 572 - color: shadowColor, 573 - inset: true, 574 - offsetY: 1, 575 - }); 576 - 577 - return { 578 - 'depth.recessed': composeShadow([innerTopShadow, ring]), 579 - 'depth.resting': composeShadow([highlight, ring, lowerEdge, exterior(1, 2, 0.35)]), 580 - 'depth.raised': composeShadow([ 581 - highlight, 582 - ring, 583 - lowerEdge, 584 - exterior(2, 4, 0.5), 585 - exterior(1, 2, 0.35), 586 - ]), 587 - 'depth.floating': composeShadow([ring, exterior(4, 12, 0.7), exterior(2, 4, 0.4)]), 588 - 'depth.overlay': composeShadow([ring, exterior(12, 32, 0.9), exterior(4, 12, 0.5)]), 589 - }; 590 - } 591 - 592 - interface ShadowLayer { 593 - alpha: number; 594 - blur?: number; 595 - color: Oklch; 596 - inset?: boolean; 597 - offsetY?: number; 598 - spread?: number; 599 - } 600 - 601 - function shadowLayer(layer: ShadowLayer): string | null { 602 - if (layer.alpha <= 0) return null; 603 - const parts = [ 604 - ...(layer.inset === true ? ['inset'] : []), 605 - '0', 606 - pixels(layer.offsetY ?? 0), 607 - pixels(layer.blur ?? 0), 608 - ...(layer.spread !== undefined ? [pixels(layer.spread)] : []), 609 - formatOklch(layer.color, Math.min(layer.alpha, 1)), 610 - ]; 611 - return parts.join(' '); 612 - } 613 - 614 - function composeShadow(layers: Array<string | null>): string { 615 - const present = layers.filter((layer) => layer !== null); 616 - return present.length === 0 ? 'none' : present.join(', '); 617 - } 618 - 619 - function pixels(value: number): string { 620 - if (value === 0) return '0'; 621 - return `${Number(value.toFixed(2)).toString()}px`; 622 - } 623 - 624 536 function validateFoundation(foundation: ThemeFoundation): void { 625 537 const issues: Array<string> = []; 626 538 try { ··· 639 551 issues.push(`${mode}.color.${field}: ${errorMessage(error)}`); 640 552 } 641 553 } 642 - const material = modeFoundation.material; 643 - for (const field of ['highlightStrength', 'edgeStrength', 'shadowStrength'] as const) { 644 - const value = material[field]; 645 - if (!Number.isFinite(value) || value < 0 || value > 1) { 646 - issues.push(`${mode}.material.${field}: must be a number between 0 and 1`); 554 + for (const [name, value] of Object.entries(modeFoundation.depth)) { 555 + if (value.trim() === '' || /[;{}]/.test(value)) { 556 + issues.push(`${mode}.depth.${name}: must be a non-empty CSS box-shadow value`); 647 557 } 648 558 } 649 - if (!Number.isFinite(material.lowerEdgeDepth) || material.lowerEdgeDepth < 0) { 650 - issues.push(`${mode}.material.lowerEdgeDepth: must be a number of pixels, 0 or greater`); 651 - } 652 - try { 653 - parseColor(material.shadowColor); 654 - } catch (error) { 655 - issues.push(`${mode}.material.shadowColor: ${errorMessage(error)}`); 559 + for (const [name, value] of Object.entries(modeFoundation.actionControlFinish)) { 560 + if (value.trim() === '' || /[;{}]/.test(value)) { 561 + issues.push( 562 + `${mode}.actionControlFinish.${name}: must be a non-empty CSS background-image value`, 563 + ); 564 + } 656 565 } 657 566 } 658 567 const fontFamily = foundation.typography?.fontFamily; ··· 690 599 ): string { 691 600 const selector = `.${themeClassName(foundation.name)}`; 692 601 const pairs = flattenThemeContract(); 693 - const isModePath = (path: string) => path.startsWith('color.') || path.startsWith('depth.'); 602 + const isModePath = (path: string) => 603 + path.startsWith('actionControlFinish.') || 604 + path.startsWith('color.') || 605 + path.startsWith('depth.'); 694 606 const identityPairs = pairs.filter(([path]) => !isModePath(path)); 695 607 const modePairs = pairs.filter(([path]) => isModePath(path)); 696 608
+1
packages/@luke-ui/react/src/theme/contract.ts
··· 92 92 }, 93 93 }, 94 94 depth: { recessed: null, resting: null, raised: null, floating: null, overlay: null }, 95 + actionControlFinish: { recessed: null, resting: null, raised: null }, 95 96 font: { 96 97 100: { fontSize: null, letterSpacing: null, lineHeight: null }, 97 98 200: { fontSize: null, letterSpacing: null, lineHeight: null },
+27 -17
packages/@luke-ui/react/src/theme/foundation.ts
··· 77 77 }; 78 78 } 79 79 80 - /** The per-mode authored inputs: source colours and a material profile. */ 80 + /** The per-mode authored inputs: source colours, action-control finish, and depth treatments. */ 81 81 export interface ThemeModeFoundation { 82 + /** Final `background-image` values for the shared Button and IconButton face finish. */ 83 + actionControlFinish: ActionControlFinishFoundation; 82 84 /** Source colours the semantic colour contract is generated from. */ 83 85 color: ThemeSourceColors; 84 - /** The visible physical material properties the depth ladder is generated from. */ 85 - material: ThemeMaterialProfile; 86 + /** Final composite `box-shadow` values for the semantic depth ladder. */ 87 + depth: ThemeDepthFoundation; 88 + } 89 + 90 + /** Authored action-control face lighting for one colour mode. */ 91 + export interface ActionControlFinishFoundation { 92 + /** Face lighting for a pressed control. */ 93 + recessed: string; 94 + /** Face lighting for a resting control. */ 95 + resting: string; 96 + /** Face lighting for a hovered control. */ 97 + raised: string; 86 98 } 87 99 88 100 /** ··· 109 121 focus?: string; 110 122 } 111 123 112 - /** Visible physical material properties for one mode. */ 113 - export interface ThemeMaterialProfile { 114 - /** Alpha of the top inner highlight line, 0 to 1. */ 115 - highlightStrength: number; 116 - /** Alpha of the inset perimeter ring, 0 to 1. */ 117 - edgeStrength: number; 118 - /** Colour of exterior and inset shadows. */ 119 - shadowColor: string; 120 - /** Scale applied to exterior shadow alphas, 0 to 1. */ 121 - shadowStrength: number; 122 - /** Blur character; scales shadow blur radii. */ 123 - blur: 'sharp' | 'soft'; 124 - /** Depth of the inset lower edge in pixels. */ 125 - lowerEdgeDepth: number; 124 + /** Authored composite `box-shadow` values for one colour mode. */ 125 + export interface ThemeDepthFoundation { 126 + /** Inset treatment for a pressed control or sunken surface. */ 127 + recessed: string; 128 + /** Resting treatment for an interactive control or surface. */ 129 + resting: string; 130 + /** Treatment for a hovered control or elevated surface. */ 131 + raised: string; 132 + /** Treatment for a floating surface such as a menu. */ 133 + floating: string; 134 + /** Treatment for a high-elevation surface such as a dialog. */ 135 + overlay: string; 126 136 } 127 137 128 138 /** Curated Capsize-compatible font stacks for each font-family choice. */
+112 -30
packages/@luke-ui/react/src/theme/foundations.ts
··· 6 6 */ 7 7 export const machinedEdgeFoundation: ThemeFoundation = { 8 8 dark: { 9 + actionControlFinish: { 10 + raised: [ 11 + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.18) 0%, transparent 100%)', 12 + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)', 13 + ].join(', '), 14 + recessed: [ 15 + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)', 16 + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.04) 0%, transparent 70%)', 17 + ].join(', '), 18 + resting: [ 19 + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.14) 0%, transparent 100%)', 20 + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)', 21 + ].join(', '), 22 + }, 9 23 color: { 10 24 accent: 'oklch(0.75 0.1 200)', 11 25 neutral: 'oklch(0.25 0.015 210)', 12 26 }, 13 - material: { 14 - blur: 'sharp', 15 - edgeStrength: 0.55, 16 - highlightStrength: 0.14, 17 - lowerEdgeDepth: 2, 18 - shadowColor: 'oklch(0.05 0.01 220)', 19 - shadowStrength: 0.55, 27 + depth: { 28 + floating: [ 29 + '0 4px 12px oklch(0.05 0.01 220 / 0.38)', 30 + '0 2px 4px oklch(0.05 0.01 220 / 0.22)', 31 + ].join(', '), 32 + overlay: [ 33 + '0 12px 32px oklch(0.05 0.01 220 / 0.5)', 34 + '0 4px 12px oklch(0.05 0.01 220 / 0.28)', 35 + ].join(', '), 36 + raised: [ 37 + '0 3px 0 oklch(0.05 0.01 220 / 0.55)', 38 + '0 5px 8px -2px oklch(0.05 0.01 220 / 0.32)', 39 + ].join(', '), 40 + recessed: 'none', 41 + resting: [ 42 + '0 2px 0 oklch(0.05 0.01 220 / 0.5)', 43 + '0 3px 5px -1px oklch(0.05 0.01 220 / 0.26)', 44 + ].join(', '), 20 45 }, 21 46 }, 22 47 light: { 48 + actionControlFinish: { 49 + raised: [ 50 + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.3) 0%, transparent 100%)', 51 + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.16) 0%, transparent 70%)', 52 + ].join(', '), 53 + recessed: [ 54 + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.12) 0%, transparent 100%)', 55 + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.06) 0%, transparent 70%)', 56 + ].join(', '), 57 + resting: [ 58 + 'radial-gradient(80% 70% at 50% 0%, rgb(255 255 255 / 0.24) 0%, transparent 100%)', 59 + 'radial-gradient(70% 45% at 50% 110%, rgb(255 255 255 / 0.12) 0%, transparent 70%)', 60 + ].join(', '), 61 + }, 23 62 color: { 24 63 accent: 'oklch(0.52 0.11 200)', 25 64 neutral: 'oklch(0.975 0.008 210)', 26 65 }, 27 - material: { 28 - blur: 'sharp', 29 - edgeStrength: 0.45, 30 - highlightStrength: 0.9, 31 - lowerEdgeDepth: 2, 32 - shadowColor: 'oklch(0.3 0.03 220)', 33 - shadowStrength: 0.22, 66 + depth: { 67 + floating: [ 68 + '0 4px 12px oklch(0.3 0.03 220 / 0.16)', 69 + '0 2px 4px oklch(0.3 0.03 220 / 0.1)', 70 + ].join(', '), 71 + overlay: [ 72 + '0 12px 32px oklch(0.3 0.03 220 / 0.2)', 73 + '0 4px 12px oklch(0.3 0.03 220 / 0.12)', 74 + ].join(', '), 75 + raised: [ 76 + '0 3px 0 oklch(0.3 0.03 220 / 0.3)', 77 + '0 5px 8px -2px oklch(0.3 0.03 220 / 0.2)', 78 + ].join(', '), 79 + recessed: 'none', 80 + resting: [ 81 + '0 2px 0 oklch(0.3 0.03 220 / 0.28)', 82 + '0 3px 5px -1px oklch(0.3 0.03 220 / 0.16)', 83 + ].join(', '), 34 84 }, 35 85 }, 36 86 name: 'machined-edge', ··· 38 88 39 89 /** 40 90 * Foundation for ELMO, the materially minimal bundled theme. Its light mode approximates the flat, 41 - * hairline-bordered Luke UI look with the blue `#0160ae`-family accent; its dark mode is net-new. 91 + * hairline-bordered Luke UI look with the blue `#185281`-family accent; its dark mode is net-new. 42 92 */ 43 93 export const elmoFoundation: ThemeFoundation = { 44 94 dark: { 95 + actionControlFinish: { 96 + raised: [ 97 + 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.1) 0%, transparent 100%)', 98 + 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.05) 0%, transparent 70%)', 99 + ].join(', '), 100 + recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.04) 0%, transparent 100%)', 101 + resting: [ 102 + 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.07) 0%, transparent 100%)', 103 + 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.03) 0%, transparent 70%)', 104 + ].join(', '), 105 + }, 45 106 color: { 46 107 accent: 'oklch(0.7 0.11 250)', 47 108 neutral: 'oklch(0.22 0.01 250)', 48 109 }, 49 - material: { 50 - blur: 'soft', 51 - edgeStrength: 0.45, 52 - highlightStrength: 0.05, 53 - lowerEdgeDepth: 0, 54 - shadowColor: 'oklch(0.12 0.01 250)', 55 - shadowStrength: 0.35, 110 + depth: { 111 + floating: '0 4px 14px oklch(0.12 0.01 250 / 0.25)', 112 + overlay: '0 12px 36px oklch(0.12 0.01 250 / 0.32)', 113 + raised: [ 114 + '0 2px 6px oklch(0.12 0.01 250 / 0.18)', 115 + '0 1px 3px oklch(0.12 0.01 250 / 0.12)', 116 + ].join(', '), 117 + recessed: 'none', 118 + resting: [ 119 + '0 1px 3px oklch(0.12 0.01 250 / 0.12)', 120 + '0 1px 2px oklch(0.12 0.01 250 / 0.06)', 121 + ].join(', '), 56 122 }, 57 123 }, 58 124 light: { 125 + actionControlFinish: { 126 + raised: [ 127 + 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.2) 0%, transparent 100%)', 128 + 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.1) 0%, transparent 70%)', 129 + ].join(', '), 130 + recessed: 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.08) 0%, transparent 100%)', 131 + resting: [ 132 + 'radial-gradient(90% 75% at 50% 0%, rgb(255 255 255 / 0.16) 0%, transparent 100%)', 133 + 'radial-gradient(80% 50% at 50% 110%, rgb(255 255 255 / 0.07) 0%, transparent 70%)', 134 + ].join(', '), 135 + }, 59 136 color: { 60 - accent: '#0160ae', 137 + accent: '#185281', 61 138 danger: '#c0262e', 62 139 info: '#1d39c4', 63 140 neutral: '#ffffff', 64 141 success: '#306317', 65 142 warning: '#d89614', 66 143 }, 67 - material: { 68 - blur: 'soft', 69 - edgeStrength: 0.35, 70 - highlightStrength: 0, 71 - lowerEdgeDepth: 0, 72 - shadowColor: 'oklch(0.2 0.01 250)', 73 - shadowStrength: 0.1, 144 + depth: { 145 + floating: '0 4px 14px oklch(0.2 0.01 250 / 0.12)', 146 + overlay: '0 12px 36px oklch(0.2 0.01 250 / 0.16)', 147 + raised: [ 148 + '0 2px 6px oklch(0.2 0.01 250 / 0.05)', 149 + '0 1px 3px oklch(0.2 0.01 250 / 0.035)', 150 + ].join(', '), 151 + recessed: 'none', 152 + resting: [ 153 + '0 1px 3px oklch(0.2 0.01 250 / 0.04)', 154 + '0 1px 2px oklch(0.2 0.01 250 / 0.02)', 155 + ].join(', '), 74 156 }, 75 157 }, 76 158 name: 'elmo',
+2 -1
packages/@luke-ui/react/src/theme/index.tsx
··· 34 34 35 35 /** The typed theme-foundation contract accepted by `buildTheme`. */ 36 36 export type { 37 + ActionControlFinishFoundation, 38 + ThemeDepthFoundation, 37 39 ThemeFoundation, 38 - ThemeMaterialProfile, 39 40 ThemeModeFoundation, 40 41 ThemeSourceColors, 41 42 } from './foundation.js';
+11 -9
apps/docs/content/docs/components/actions/button.mdx
··· 1 1 --- 2 2 title: Button 3 - description: Action button with tone, size, icon, pending, and disabled states. 3 + description: Action button with semantic tone and appearance options. 4 4 --- 5 5 6 6 `Button` expects the Luke UI theme class at the app or root level. See ··· 14 14 15 15 | Guidance | Practices | 16 16 | -------- | -------------------------------------------------------------------------------------------------------------- | 17 - | Do | Use one `tone="primary"` button for the main action in a view. Use `neutral` or `ghost` for secondary actions. | 17 + | Do | Use `tone="accent"` for the main action. Use a subtle or ghost appearance for secondary actions. | 18 18 | Do | Write a label that names the action, such as "Save changes" or "Delete account". Avoid vague labels like "OK". | 19 19 | Do | Set `isPending` while an action is in flight so the user can see that work is still happening. | 20 20 | Don't | Use `Button` for navigation. If the control only moves the user to another page, use `Link`. | 21 21 22 - ## Tone 22 + ## Tone and appearance 23 23 24 - `Button` has four tones: `primary` (default), `neutral`, `critical`, and `ghost`. 24 + Tone communicates intent: `neutral` (default), `accent`, or `danger`. Appearance controls emphasis: 25 + `solid` (default), `subtle`, or `ghost`. Every tone supports every appearance. 25 26 26 27 <ExampleBlock 27 - src="button/tones" 28 - title="Button — Tones" 29 - description="Primary, critical, ghost, and neutral tones for different emphasis levels." 28 + src="button/tone-and-appearance" 29 + title="Button — Tone and appearance" 30 + description="Each semantic tone in solid, subtle, and ghost appearances." 30 31 /> 31 32 32 33 ## Size ··· 60 61 61 62 ## Pending 62 63 63 - Set `isPending` while an action is in flight. A spinner overlays the label and the button becomes 64 - non-interactive. 64 + Set `isPending` while an action is in flight. A spinner replaces the label. The button remains 65 + focusable and reports its pending state, but cannot be pressed again. It uses the same muted visual 66 + treatment as a disabled button while keeping the spinner visible. 65 67 66 68 <ExampleBlock 67 69 src="button/pending"
+13 -5
apps/docs/content/docs/components/actions/icon-button.mdx
··· 15 15 | Guidance | Practices | 16 16 | -------- | ---------------------------------------------------------------------------- | 17 17 | Do | Name the action in `aria-label`, such as "Delete row". Do not name the icon. | 18 - | Do | Use `tone="critical"` for destructive icon actions such as delete. | 18 + | Do | Use `tone="danger"` for destructive icon actions such as delete. | 19 19 20 - ## Tone 20 + ## Tone and appearance 21 + 22 + `IconButton` shares Button's appearance API. Tone can be `neutral` (default), `accent`, or `danger`. 23 + Appearance can be `solid` (default), `subtle`, or `ghost`. 21 24 22 25 <ExampleBlock 23 - src="icon-button/tones" 24 - title="Icon Button — Tone" 25 - description="Ghost and critical tones for icon-only actions." 26 + src="icon-button/tone-and-appearance" 27 + title="Icon Button — Tone and appearance" 28 + description="Semantic tones and appearances for icon-only actions." 26 29 /> 27 30 28 31 ## Size ··· 32 35 title="Icon Button — Size" 33 36 description="A small icon button for tighter spaces." 34 37 /> 38 + 39 + ## Pending 40 + 41 + Set `isPending` while an action is in flight. The button remains focusable but cannot be pressed 42 + again. It uses the disabled visual treatment and replaces the icon with a busy cue. 35 43 36 44 ## Accessibility 37 45
+8 -5
apps/docs/content/docs/components/primitives/button.mdx
··· 9 9 ```tsx 10 10 import { Button } from '@luke-ui/react/button/primitive'; 11 11 12 - <Button tone="primary">Save</Button>; 12 + <Button appearance="solid" tone="accent"> 13 + Save 14 + </Button>; 13 15 ``` 14 16 15 17 ## When to use ··· 21 23 22 24 ## Behaviour 23 25 24 - The primitive renders a single `<button>` element by default. It applies Luke UI size, tone, and 25 - block styles, then renders children directly. 26 + The primitive renders a single `<button>` element by default. It applies Luke UI size, tone, 27 + appearance, and block styles, then renders children directly. 26 28 27 - `isPending` disables the button, but it does not add a spinner or hide the label. Build that UI in 28 - children when you use the primitive. 29 + `isPending` keeps the button focusable while preventing another press and applies the disabled 30 + visual treatment. It does not add a spinner or hide the label. Build that cue in children when you 31 + use the primitive. 29 32 30 33 ## Props 31 34
+18 -9
packages/@luke-ui/react/src/button/primitive/index.tsx
··· 7 7 import { BUTTON_ICON_SIZE } from '../../sizing/button-sizing.js'; 8 8 import { cx } from '../../utils/index.js'; 9 9 10 - interface ButtonVariantProps extends NonNullable<styles.ButtonVariants> {} 10 + interface ButtonRecipeProps extends NonNullable<styles.ButtonVariants> {} 11 11 12 12 interface ButtonStyleProps { 13 + /** 14 + * Visual emphasis. 15 + * @default 'solid' 16 + */ 17 + appearance?: ButtonRecipeProps['appearance']; 13 18 /** 14 19 * Whether the button takes up the full inline size of its container. 15 20 * @default false 16 21 */ 17 - isBlock?: ButtonVariantProps['isBlock']; 22 + isBlock?: ButtonRecipeProps['isBlock']; 18 23 /** 19 24 * Sets the button size. 20 25 * @default 'medium' 21 26 */ 22 - size?: ButtonVariantProps['size']; 23 - /** Visual tone. Controls colour scheme. */ 24 - tone?: ButtonVariantProps['tone']; 27 + size?: ButtonRecipeProps['size']; 28 + /** 29 + * Visual tone. Controls colour scheme. 30 + * @default 'neutral' 31 + */ 32 + tone?: ButtonRecipeProps['tone']; 25 33 } 26 34 27 35 /** 28 - * Primitive button — a bare `<button>` styled with size, tone, and block variants. 36 + * Primitive button — a bare `<button>` styled with size, tone, appearance, and block options. 29 37 * Library-author audience: use this when you need full control over children layout. 30 38 * 31 39 * @tier primitive ··· 36 44 /** Primitive button. See `ButtonProps`. */ 37 45 export function Button(props: ButtonProps): JSX.Element { 38 46 const { 47 + appearance = 'solid', 39 48 children, 40 49 isBlock = false, 41 50 isDisabled = false, 42 51 isPending = false, 43 52 size = 'medium', 44 - tone, 53 + tone = 'neutral', 45 54 ...restProps 46 55 } = props; 47 56 const iconSize = BUTTON_ICON_SIZE[size]; ··· 51 60 <RacButton 52 61 {...restProps} 53 62 className={composeRenderProps(props.className, (className) => { 54 - return cx(styles.button({ isBlock, size, tone }), className); 63 + return cx(styles.button({ appearance, isBlock, size, tone }), className); 55 64 })} 56 - isDisabled={isDisabled || isPending} 65 + isDisabled={isDisabled} 57 66 isPending={isPending} 58 67 > 59 68 {children}