diff --git a/.changeset/tactile-field-wells.md b/.changeset/tactile-field-wells.md new file mode 100644 index 00000000..130c3552 --- /dev/null +++ b/.changeset/tactile-field-wells.md @@ -0,0 +1,8 @@ +--- +'@luke-ui/react': patch +--- + +Move `Field`, `TextInput`, and `TextField` onto the new semantic theme contract. The input control +now renders as a recessed well with an accent-coloured hover and focus edge, and read-only fields +flatten into the canvas surface instead of relying on colour alone. No props or exported types +changed. diff --git a/apps/docs/content/docs/components/primitives/text-input.mdx b/apps/docs/content/docs/components/primitives/text-input.mdx index 67d92ba6..fc051cef 100644 --- a/apps/docs/content/docs/components/primitives/text-input.mdx +++ b/apps/docs/content/docs/components/primitives/text-input.mdx @@ -27,6 +27,10 @@ semantics and focus behaviour. Pass `className` to style the outer group wrapper. Pass `inputClassName` to style the input element. +The control renders as a recessed well. Hover and focus both show an accent-coloured edge; read-only +flattens the well into the canvas surface instead of using colour, so it stays distinguishable from +an editable field even without focus. + ## Props ; /** Vanilla-extract recipe for the `Field` primitive's message styles. */ export const fieldMessage = recipeInLayer('recipes', { base: { - fontSize: vars.font.size.small, - lineHeight: vars.font.lineHeight.tight, + fontSize: vars.font[200].fontSize, + letterSpacing: vars.font[200].letterSpacing, + lineHeight: vars.font[200].lineHeight, minInlineSize: 0, selectors: { [`${dataDisabledSelector} &`]: { - color: vars.foregroundColor.disabled, + color: vars.color.textDisabled, }, }, }, @@ -79,10 +81,10 @@ export const fieldMessage = recipeInLayer('recipes', { variants: { tone: { description: { - color: vars.foregroundColor.secondary, + color: vars.color.text.secondary, }, error: { - color: vars.foregroundColor.critical, + color: vars.color.intent.danger.text, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts b/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts index 2cdade43..6b2c4afa 100644 --- a/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts @@ -1,7 +1,6 @@ import { afterEach, expect, test } from 'vite-plus/test'; import { themeClass, vars } from '../styles/vars.css.js'; import { comboboxControl, comboboxItem } from './combobox.css.js'; -import { textInputAdornmentStart, textInputGroup } from './text-input.css.js'; let wrappers: Array = []; @@ -43,17 +42,6 @@ test('a resting combobox control is not styled as read-only by its trigger butto expect(getComputedStyle(control).backgroundColor).toBe(resolveColor(vars.backgroundColor.input)); }); -test('a resting text input group is not styled as read-only by its adornment', () => { - const group = document.createElement('div'); - group.className = textInputGroup({ size: 'medium' }); - const adornment = document.createElement('span'); - adornment.className = textInputAdornmentStart({ size: 'medium' }); - group.append(adornment, document.createElement('input')); - mount(group); - - expect(getComputedStyle(group).backgroundColor).toBe(resolveColor(vars.backgroundColor.input)); -}); - test('a combobox control with a read-only input gets the read-only treatment', () => { const control = comboboxControlElement(); control.querySelector('input')?.setAttribute('readonly', ''); @@ -73,21 +61,10 @@ test('disabled beats read-only on a combobox control', () => { expect(style.borderColor).toBe(resolveColor(vars.border.default)); }); -test('text input and combobox share the same focus ring color', () => { - const group = document.createElement('div'); - group.className = textInputGroup({ size: 'medium' }); - group.append(document.createElement('input')); - mount(group); - const control = mount(comboboxControlElement()); - - group.querySelector('input')?.focus(); - const groupOutline = getComputedStyle(group).outlineColor; - control.querySelector('input')?.focus(); - const controlOutline = getComputedStyle(control).outlineColor; - - expect(groupOutline).toBe(resolveColor(vars.themeColor.focusRingColor)); - expect(controlOutline).toBe(groupOutline); -}); +// `TextInput`'s focus ring moved to the new semantic theme contract in #99; +// Combobox still authors its own ring from the old token system until it +// migrates (#100), so the two are no longer guaranteed to share a color. See +// `recipes/text-input.browser.test.ts` for the migrated focus-ring contract. test('keyboard-focused combobox items are indicated by background, not a second ring', () => { const item = document.createElement('li'); diff --git a/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts b/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts new file mode 100644 index 00000000..2fb1f9be --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts @@ -0,0 +1,168 @@ +import '@luke-ui/react/themes/machined-edge.css'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { themeRootClassName } from '../theme/index.js'; +import { machinedEdgeThemeClassName } from '../themes/index.js'; +import { cx } from '../utils/index.js'; +import { textInputAdornmentStart, textInputGroup } from './text-input.css.js'; + +let mounted: Array = []; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; +}); + +test('the well uses the recessed surface, depth, and control radius', () => { + const { group, root } = mountGroup(); + const probe = root.appendChild(document.createElement('div')); + probe.style.backgroundColor = 'var(--luke-color-surface-recessed)'; + probe.style.boxShadow = 'var(--luke-depth-recessed)'; + const style = getComputedStyle(group); + + expect(style.backgroundColor).toBe(getComputedStyle(probe).backgroundColor); + expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(style.boxShadow).toBe(getComputedStyle(probe).boxShadow); + expect(style.boxShadow).not.toBe('none'); + expect(style.borderRadius).toBe(getComputedStyle(root).getPropertyValue('--luke-radius-control')); +}); + +test('small and medium share the control geometry contract', () => { + const { group: small } = mountGroup({ size: 'small' }); + const { group: medium } = mountGroup({ size: 'medium' }); + + expect(getComputedStyle(small).blockSize).toBe('32px'); + expect(getComputedStyle(medium).blockSize).toBe('40px'); +}); + +test('hover sets the accent border only when not disabled, read-only, or focus-within', () => { + const { group: resting } = mountGroup(); + const { group: hovered } = mountGroup(); + hovered.dataset.hovered = 'true'; + + const restingBorder = getComputedStyle(resting).borderColor; + const hoveredBorder = getComputedStyle(hovered).borderColor; + expect(hoveredBorder).not.toBe(restingBorder); + + const { group: disabledHover } = mountGroup(); + disabledHover.dataset.hovered = 'true'; + disabledHover.dataset.disabled = 'true'; + expect(getComputedStyle(disabledHover).borderColor).toBe(restingBorder); + + const { group: readOnlyHover } = mountGroup(); + readOnlyHover.dataset.hovered = 'true'; + readOnlyHover.dataset.readonly = 'true'; + expect(getComputedStyle(readOnlyHover).borderColor).not.toBe(hoveredBorder); + + const { group: focusWithinOnly } = mountGroup(); + focusWithinOnly.dataset.focusWithin = 'true'; + const { group: focusWithinHover } = mountGroup(); + focusWithinHover.dataset.focusWithin = 'true'; + focusWithinHover.dataset.hovered = 'true'; + expect(getComputedStyle(focusWithinHover).borderColor).toBe( + getComputedStyle(focusWithinOnly).borderColor, + ); +}); + +test('focus-within shows the complete ring and the accent border', () => { + const { group } = mountGroup(); + group.dataset.focusWithin = 'true'; + const style = getComputedStyle(group); + + expect(style.outlineStyle).toBe('solid'); + expect(style.outlineWidth).toBe('2px'); + expect(style.outlineOffset).toBe('2px'); + expect(style.outlineColor).not.toBe('rgba(0, 0, 0, 0)'); + + const { group: resting } = mountGroup(); + expect(style.borderColor).not.toBe(getComputedStyle(resting).borderColor); +}); + +test('invalid shows the danger border even while focus-within, ring stays the same focus color', () => { + const { group: invalid } = mountGroup(); + invalid.dataset.invalid = 'true'; + const invalidBorder = getComputedStyle(invalid).borderColor; + + const { group: focusRing } = mountGroup(); + focusRing.dataset.focusWithin = 'true'; + const focusOutline = getComputedStyle(focusRing).outlineColor; + + const { group: invalidFocus } = mountGroup(); + invalidFocus.dataset.invalid = 'true'; + invalidFocus.dataset.focusWithin = 'true'; + const invalidFocusStyle = getComputedStyle(invalidFocus); + + expect(invalidFocusStyle.borderColor).toBe(invalidBorder); + expect(invalidFocusStyle.outlineColor).toBe(focusOutline); + + const { group: resting } = mountGroup(); + expect(invalidBorder).not.toBe(getComputedStyle(resting).borderColor); +}); + +test('disabled preserves resting background and border, only dropping opacity', () => { + const { group: resting } = mountGroup(); + const { group: disabled } = mountGroup(); + disabled.dataset.disabled = 'true'; + disabled.dataset.hovered = 'true'; + + expect(getComputedStyle(disabled).backgroundColor).toBe( + getComputedStyle(resting).backgroundColor, + ); + expect(getComputedStyle(disabled).borderColor).toBe(getComputedStyle(resting).borderColor); + expect(getComputedStyle(disabled).opacity).toBe('0.55'); + expect(getComputedStyle(disabled).cursor).toBe('not-allowed'); +}); + +test('read-only flattens into canvas surface with a decorative border and ignores hover', () => { + const { group: resting } = mountGroup(); + const { group: readOnly } = mountGroup(); + readOnly.dataset.readonly = 'true'; + const { group: readOnlyHover } = mountGroup(); + readOnlyHover.dataset.readonly = 'true'; + readOnlyHover.dataset.hovered = 'true'; + + const restingBackground = getComputedStyle(resting).backgroundColor; + const readOnlyStyle = getComputedStyle(readOnly); + + expect(readOnlyStyle.backgroundColor).not.toBe(restingBackground); + expect(readOnlyStyle.borderColor).not.toBe(getComputedStyle(resting).borderColor); + expect(readOnlyStyle.boxShadow).toBe('none'); + expect(getComputedStyle(readOnlyHover).borderColor).toBe(readOnlyStyle.borderColor); +}); + +test('read-only still shows the focus ring since read-only fields remain focusable', () => { + const { group } = mountGroup(); + group.dataset.readonly = 'true'; + group.dataset.focusWithin = 'true'; + const style = getComputedStyle(group); + + expect(style.outlineStyle).toBe('solid'); + expect(style.outlineWidth).toBe('2px'); +}); + +test('adornment divider uses the control border color and disabled text color follows the group', () => { + const { group, root } = mountGroup(); + const adornment = group.appendChild(document.createElement('span')); + adornment.className = textInputAdornmentStart({ size: 'medium' }); + + const controlBorderProbe = root.appendChild(document.createElement('div')); + controlBorderProbe.style.borderColor = 'var(--luke-color-border-control)'; + expect(getComputedStyle(adornment).borderInlineEndColor).toBe( + getComputedStyle(controlBorderProbe).borderColor, + ); + + group.dataset.disabled = 'true'; + const disabledTextProbe = root.appendChild(document.createElement('div')); + disabledTextProbe.style.color = 'var(--luke-color-text-disabled)'; + expect(getComputedStyle(adornment).color).toBe(getComputedStyle(disabledTextProbe).color); +}); + +function mountGroup(options: Parameters[0] = {}) { + const root = document.body.appendChild(document.createElement('div')); + root.className = cx(themeRootClassName, machinedEdgeThemeClassName); + root.dataset.colorMode = 'light'; + const group = root.appendChild(document.createElement('div')); + group.className = textInputGroup(options); + group.style.transition = 'none'; + mounted.push(root); + return { group, root }; +} diff --git a/packages/@luke-ui/react/src/recipes/text-input.css.ts b/packages/@luke-ui/react/src/recipes/text-input.css.ts index e80afecf..528b6de9 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.css.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.css.ts @@ -1,13 +1,106 @@ import type { RecipeVariants } from '@vanilla-extract/recipes'; import { recipeInLayer } from '../styles/layered-style.css.js'; -import { vars } from '../styles/vars.css.js'; -import { descendantDisabledSelector, inputChromeStyles, inputStates } from './input-states.css.js'; +import { vars } from '../theme/contract.css.js'; +import { descendantDisabledSelector, inputStates } from './input-states.css.js'; -/** Vanilla-extract recipe for the `TextInput` group styles. */ +// The well's own selector composition. Mirrors the "defaults" shape that +// `input-states.css.ts` also composes internally, but stays independent of +// its old-token style values: `Combobox` has not migrated to the new +// semantic contract yet (see the linked "Migrate Combobox" issue), and this +// slice must not change its rendered output. +const notDisabled = `:not(:where(${inputStates.disabled}))`; +const disabled = `&:where(${inputStates.disabled})`; +const focusWithin = `&:where(${inputStates.focusWithin})${notDisabled}`; +const hover = `&:where(${inputStates.hover})${notDisabled}:not(:where(${inputStates.focusWithin})):not(:where(${inputStates.readOnly}))`; +const invalid = `&:where(${inputStates.invalid})${notDisabled}`; +const invalidFocusWithin = `&:where(${inputStates.invalid}):where(${inputStates.focusWithin})${notDisabled}`; +const readOnly = `&:where(${inputStates.readOnly})${notDisabled}`; +const readOnlyFocusWithin = `&:where(${inputStates.readOnly}):where(${inputStates.focusWithin})${notDisabled}`; + +const focusRing = { + outlineColor: vars.color.border.focus, + outlineOffset: '2px', + outlineStyle: 'solid', + outlineWidth: '2px', +} as const; + +/** Vanilla-extract recipe for the `TextInput` group's tactile well chrome. */ export const textInputGroup = recipeInLayer('recipes', { base: { - ...inputChromeStyles(inputStates), + '@media': { + '(forced-colors: active)': { + backgroundColor: 'Field', + borderColor: 'FieldText', + boxShadow: 'none', + color: 'FieldText', + forcedColorAdjust: 'auto', + selectors: { + [disabled]: { + borderColor: 'GrayText', + color: 'GrayText', + opacity: 1, + }, + [focusWithin]: { + outlineColor: 'Highlight', + }, + [invalidFocusWithin]: { + outlineColor: 'Highlight', + }, + }, + }, + }, + alignItems: 'center', + backgroundColor: vars.color.surface.recessed, + borderColor: vars.color.border.control, + borderRadius: vars.radius.control, + borderStyle: 'solid', + borderWidth: '1px', + boxShadow: vars.depth.recessed, cursor: 'text', + display: 'inline-flex', + fontFamily: vars.font.family, + inlineSize: '100%', + isolation: 'isolate', + letterSpacing: vars.font[300].letterSpacing, + lineHeight: vars.font[300].lineHeight, + minInlineSize: 0, + outlineColor: 'transparent', + outlineOffset: 0, + outlineStyle: 'solid', + outlineWidth: '2px', + overflow: 'visible', + transitionDuration: vars.motion.duration.fast, + transitionProperty: 'background-color, border-color, color', + transitionTimingFunction: vars.motion.easing.standard, + + selectors: { + [disabled]: { + cursor: 'not-allowed', + opacity: 0.55, + }, + [focusWithin]: { + borderColor: vars.color.intent.accent.border, + ...focusRing, + }, + [hover]: { + borderColor: vars.color.intent.accent.border, + }, + [invalid]: { + borderColor: vars.color.intent.danger.border, + }, + [invalidFocusWithin]: { + borderColor: vars.color.intent.danger.border, + ...focusRing, + }, + [readOnly]: { + backgroundColor: vars.color.surface.canvas, + borderColor: vars.color.border.decorative, + boxShadow: 'none', + }, + [readOnlyFocusWithin]: { + ...focusRing, + }, + }, }, defaultVariants: { size: 'medium', @@ -16,11 +109,13 @@ export const textInputGroup = recipeInLayer('recipes', { size: { medium: { blockSize: vars.controlSize.medium, - fontSize: vars.font.size.standard, + fontSize: vars.font[300].fontSize, }, small: { blockSize: vars.controlSize.small, - fontSize: vars.font.size.small, + fontSize: vars.font[200].fontSize, + letterSpacing: vars.font[200].letterSpacing, + lineHeight: vars.font[200].lineHeight, }, }, }, @@ -34,14 +129,15 @@ export const textInputControl = recipeInLayer('recipes', { borderColor: 'transparent', borderStyle: 'none', borderWidth: 0, - color: vars.themeColor.inputColor, + color: vars.color.text.primary, cursor: 'text', flex: 1, fontFamily: 'inherit', fontSize: 'inherit', fontWeight: 'inherit', inlineSize: '100%', - lineHeight: vars.font.lineHeight.tight, + letterSpacing: 'inherit', + lineHeight: 'inherit', minInlineSize: 0, outlineColor: 'transparent', outlineStyle: 'none', @@ -51,11 +147,11 @@ export const textInputControl = recipeInLayer('recipes', { selectors: { '&::placeholder': { - color: vars.foregroundColor.secondary, + color: vars.color.text.secondary, opacity: 1, }, '&:where([data-disabled="true"], :disabled)': { - color: vars.foregroundColor.disabled, + color: vars.color.textDisabled, cursor: 'not-allowed', }, }, @@ -67,13 +163,13 @@ export const textInputControl = recipeInLayer('recipes', { size: { medium: { blockSize: vars.controlSize.medium, - paddingInlineEnd: vars.space.small, - paddingInlineStart: vars.space.small, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], }, small: { blockSize: vars.controlSize.small, - paddingInlineEnd: vars.space.xsmall, - paddingInlineStart: vars.space.xsmall, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], }, }, }, @@ -83,18 +179,17 @@ export const textInputControl = recipeInLayer('recipes', { export const textInputAdornmentStart = recipeInLayer('recipes', { base: { alignItems: 'center', - borderInlineEndColor: vars.border.input, + borderInlineEndColor: vars.color.border.control, borderInlineEndStyle: 'solid', - borderInlineEndWidth: vars.borderWidth.thin, - color: vars.foregroundColor.secondary, + borderInlineEndWidth: '1px', + color: vars.color.text.secondary, display: 'inline-flex', flexShrink: 0, - lineHeight: vars.font.lineHeight.nospace, + lineHeight: 'inherit', selectors: { [descendantDisabledSelector]: { - backgroundColor: vars.backgroundColor.inputDisabled, - color: vars.foregroundColor.disabled, + color: vars.color.textDisabled, }, }, }, @@ -104,12 +199,12 @@ export const textInputAdornmentStart = recipeInLayer('recipes', { variants: { size: { medium: { - paddingInlineEnd: vars.space.small, - paddingInlineStart: vars.space.small, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], }, small: { - paddingInlineEnd: vars.space.xsmall, - paddingInlineStart: vars.space.xsmall, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], }, }, }, @@ -119,19 +214,17 @@ export const textInputAdornmentStart = recipeInLayer('recipes', { export const textInputAdornmentEnd = recipeInLayer('recipes', { base: { alignItems: 'center', - backgroundColor: vars.backgroundColor.subtle, - borderInlineStartColor: vars.border.input, + borderInlineStartColor: vars.color.border.control, borderInlineStartStyle: 'solid', - borderInlineStartWidth: vars.borderWidth.thin, - color: vars.foregroundColor.secondary, + borderInlineStartWidth: '1px', + color: vars.color.text.secondary, display: 'inline-flex', flexShrink: 0, - lineHeight: vars.font.lineHeight.nospace, + lineHeight: 'inherit', selectors: { [descendantDisabledSelector]: { - backgroundColor: vars.backgroundColor.inputDisabled, - color: vars.foregroundColor.disabled, + color: vars.color.textDisabled, }, }, }, @@ -141,12 +234,12 @@ export const textInputAdornmentEnd = recipeInLayer('recipes', { variants: { size: { medium: { - paddingInlineEnd: vars.space.small, - paddingInlineStart: vars.space.small, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], }, small: { - paddingInlineEnd: vars.space.xsmall, - paddingInlineStart: vars.space.xsmall, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], }, }, }, diff --git a/packages/@luke-ui/react/src/text-field/text-field.stories.tsx b/packages/@luke-ui/react/src/text-field/text-field.stories.tsx index ae313ef6..c77c8e6f 100644 --- a/packages/@luke-ui/react/src/text-field/text-field.stories.tsx +++ b/packages/@luke-ui/react/src/text-field/text-field.stories.tsx @@ -122,9 +122,23 @@ export const ServerValidation = meta.story({ }); /** - * Disabled and read-only states are forwarded to the field container. + * Disabled and read-only states are forwarded to the field container. Unlike + * disabled, a read-only field stays focusable and in the tab order — its + * value is still relevant to assistive technology and copy/paste, it just + * can't be edited. */ export const DisabledAndReadOnly = meta.story({ + play: async ({ canvas }) => { + const disabledInput = canvas.getByLabelText('Disabled'); + const readOnlyInput = canvas.getByLabelText('Read-only'); + + await expect(disabledInput).toBeDisabled(); + await expect(readOnlyInput).not.toBeDisabled(); + await expect(readOnlyInput).toHaveAttribute('readonly'); + + await userEvent.click(readOnlyInput); + await expect(readOnlyInput).toHaveFocus(); + }, render: () => (
{ await focusViaKeyboard(page.getByRole('textbox', { name: 'Focus me' })); await captureVisual(scene, 'text-field/focus'); }); + +test.each(visualAppearances)('material states: $theme $mode', async (appearance) => { + const scene = renderVisual( + + + + + + , + appearance, + ); + await expect.element(page.getByLabelText('Default')).toBeVisible(); + + await captureVisualAppearance(scene, 'text-field/material-states', appearance); +}); + +test.each(visualAppearances)('interactive states: $theme $mode', async (appearance) => { + const scene = renderVisual( + , + appearance, + ); + const input = page.getByRole('textbox', { name: 'Focus me' }); + await expect.element(input).toBeVisible(); + + await captureVisualAppearance(scene, 'text-field/resting', appearance); + await userEvent.hover(input); + await captureVisualAppearance(scene, 'text-field/hover', appearance); + await userEvent.unhover(input); + await focusViaKeyboard(input); + await captureVisualAppearance(scene, 'text-field/focus-visible', appearance); +}); + +test.each(visualAppearances)('invalid interactive states: $theme $mode', async (appearance) => { + const scene = renderVisual( + , + appearance, + ); + const input = page.getByRole('textbox', { name: 'Invalid' }); + await expect.element(input).toBeVisible(); + + await captureVisualAppearance(scene, 'text-field/invalid', appearance); + await focusViaKeyboard(input); + await captureVisualAppearance(scene, 'text-field/invalid-focus', appearance); +}); diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index 3d970898..46d42aa3 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -50,6 +50,10 @@ function extractValue(block: string, varName: string): string { return match[1]; } +function extractShadowOpacities(shadow: string): Array { + return [...shadow.matchAll(/\/ ([\d.]+)\)/g)].map((match) => Number(match[1])); +} + describe('buildTheme output', () => { const css = buildTheme(machinedEdgeFoundation); const blocks = splitBlocks(css); @@ -140,7 +144,9 @@ describe('buildTheme output', () => { expect(foundation[mode].depth.raised).not.toContain('inset'); expect(foundation[mode].depth.resting.split(', ')).toHaveLength(2); expect(foundation[mode].depth.raised.split(', ')).toHaveLength(2); - expect(foundation[mode].depth.recessed).toBe('none'); + const recessedLayers = foundation[mode].depth.recessed.split(', '); + expect(foundation[mode].depth.recessed).not.toBe('none'); + expect(recessedLayers.every((layer) => layer.startsWith('inset '))).toBe(true); } } }); @@ -151,10 +157,17 @@ describe('buildTheme output', () => { [elmoBlocks.baseLight, blocks.baseLight], [elmoBlocks.mediaDark, blocks.mediaDark], ] as const) { + const elmoRecessed = extractValue(elmoBlock, '--luke-depth-recessed'); + const machinedRecessed = extractValue(machinedBlock, '--luke-depth-recessed'); const elmoResting = extractValue(elmoBlock, '--luke-depth-resting'); const elmoRaised = extractValue(elmoBlock, '--luke-depth-raised'); const elmoFinish = extractValue(elmoBlock, '--luke-action-control-finish-resting'); + expect(machinedRecessed.split(', ')).toHaveLength(2); + expect(elmoRecessed.split(', ')).toHaveLength(1); + expect(Math.max(...extractShadowOpacities(machinedRecessed))).toBeGreaterThan( + Math.max(...extractShadowOpacities(elmoRecessed)), + ); expect(extractValue(machinedBlock, '--luke-depth-resting')).toContain('0 2px 0'); expect(elmoResting).not.toContain('0 2px 0'); expect(elmoRaised).not.toContain('0 3px 0'); @@ -380,6 +393,46 @@ describe('bundled themes meet WCAG 2.2 AA', () => { expect(contrastRatio(borderControl, canvas)).toBeGreaterThanOrEqual(3); } }); + + it(`${foundation.name} keeps light recessed surfaces near canvas and dark wells distinct`, () => { + const blocks = splitBlocks(buildTheme(foundation)); + const lightCanvas = parseColor(extractValue(blocks.baseLight, '--luke-color-surface-canvas')); + const lightRecessed = parseColor( + extractValue(blocks.baseLight, '--luke-color-surface-recessed'), + ); + const darkCanvas = parseColor(extractValue(blocks.mediaDark, '--luke-color-surface-canvas')); + const darkRecessed = parseColor( + extractValue(blocks.mediaDark, '--luke-color-surface-recessed'), + ); + + expect(lightCanvas.l - lightRecessed.l).toBeGreaterThan(0); + expect(lightCanvas.l - lightRecessed.l).toBeLessThanOrEqual(0.015); + expect(darkCanvas.l - darkRecessed.l).toBeGreaterThanOrEqual(0.02); + }); + + it(`${foundation.name} generates the least-contrasting passing control borders`, () => { + const blocks = splitBlocks(buildTheme(foundation)); + for (const block of [blocks.baseLight, blocks.mediaDark]) { + const surfaces = ['canvas', 'resting', 'recessed'].map((surface) => + parseColor(extractValue(block, `--luke-color-surface-${surface}`)), + ); + const borderVarNames = [ + '--luke-color-border-control', + ...['accent', 'info', 'success', 'warning', 'danger'].map( + (intent) => `--luke-color-intent-${intent}-border`, + ), + ]; + + for (const varName of borderVarNames) { + const border = parseColor(extractValue(block, varName)); + const minimumContrast = Math.min( + ...surfaces.map((surface) => contrastRatio(border, surface)), + ); + expect(minimumContrast).toBeGreaterThanOrEqual(3); + expect(minimumContrast).toBeLessThan(3.15); + } + } + }); } }); diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 2f1ef027..59b1e7c5 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -180,14 +180,24 @@ const LIGHTNESS_WINDOWS: Record = { textSecondary: [0.68, 0.88], }, light: { - borderControl: [0.35, 0.62], - intentBorder: [0.38, 0.62], + borderControl: [0.35, 0.8], + intentBorder: [0.38, 0.8], intentText: [0.25, 0.56], textPrimary: [0.1, 0.35], textSecondary: [0.3, 0.52], }, }; +// Surface roles encode usage directly: wells stay close to the light canvas while detached +// surfaces separate more strongly, without exposing generated palette steps. +const SURFACE_LIGHTNESS_DELTAS = { + dark: { floating: 0.07, overlay: 0.09, recessed: -0.025, resting: 0.04 }, + light: { floating: 0.012, overlay: 0.015, recessed: -0.01, resting: 0.012 }, +} as const satisfies Record< + ColorMode, + Record<'floating' | 'overlay' | 'recessed' | 'resting', number> +>; + interface ModeValues { failures: Array; values: Record; @@ -221,21 +231,14 @@ function buildModeColors( const canvas = gamutMapOklch({ ...neutral, c: Math.min(neutral.c, 0.015) }); const surfaceAt = (delta: number) => gamutMapOklch({ ...canvas, l: clampUnit(canvas.l + delta) }); - const surfaces = isLight - ? { - canvas, - floating: surfaceAt(0.012), - overlay: surfaceAt(0.015), - recessed: surfaceAt(-0.035), - resting: surfaceAt(0.012), - } - : { - canvas, - floating: surfaceAt(0.07), - overlay: surfaceAt(0.09), - recessed: surfaceAt(-0.025), - resting: surfaceAt(0.04), - }; + const surfaceDeltas = SURFACE_LIGHTNESS_DELTAS[mode]; + const surfaces = { + canvas, + floating: surfaceAt(surfaceDeltas.floating), + overlay: surfaceAt(surfaceDeltas.overlay), + recessed: surfaceAt(surfaceDeltas.recessed), + resting: surfaceAt(surfaceDeltas.resting), + }; const allSurfaces = [ surfaces.canvas, surfaces.resting, @@ -277,7 +280,7 @@ function buildModeColors( hue: neutral.h, mode, ratio: UI_RATIO, - startLightness: midpoint(windows.borderControl), + startLightness: lowContrastLightness(mode, windows.borderControl), window: windows.borderControl, }); colors['color.border.decorative'] = gamutMapOklch({ @@ -376,7 +379,7 @@ function buildIntentKit( hue: source.h, mode, ratio: UI_RATIO, - startLightness: source.l, + startLightness: lowContrastLightness(mode, windows.intentBorder), window: windows.intentBorder, }); return { @@ -454,6 +457,10 @@ function solveLightness(request: LightnessSolveRequest): Oklch { return makeColor(passing); } +function lowContrastLightness(mode: ColorMode, window: [number, number]): number { + return mode === 'light' ? window[1] : window[0]; +} + function minimumRatio(foreground: Oklch, backgrounds: Array): number { return Math.min(...backgrounds.map((background) => contrastRatio(foreground, background))); } diff --git a/packages/@luke-ui/react/src/theme/foundations.ts b/packages/@luke-ui/react/src/theme/foundations.ts index aeb415b1..825e015e 100644 --- a/packages/@luke-ui/react/src/theme/foundations.ts +++ b/packages/@luke-ui/react/src/theme/foundations.ts @@ -37,7 +37,10 @@ export const machinedEdgeFoundation: ThemeFoundation = { '0 3px 0 oklch(0.05 0.01 220 / 0.55)', '0 5px 8px -2px oklch(0.05 0.01 220 / 0.32)', ].join(', '), - recessed: 'none', + recessed: [ + 'inset 0 2px 4px oklch(0.05 0.01 220 / 0.45)', + 'inset 0 -1px 0 oklch(0.8 0.01 220 / 0.12)', + ].join(', '), resting: [ '0 2px 0 oklch(0.05 0.01 220 / 0.5)', '0 3px 5px -1px oklch(0.05 0.01 220 / 0.26)', @@ -76,7 +79,10 @@ export const machinedEdgeFoundation: ThemeFoundation = { '0 3px 0 oklch(0.3 0.03 220 / 0.3)', '0 5px 8px -2px oklch(0.3 0.03 220 / 0.2)', ].join(', '), - recessed: 'none', + recessed: [ + 'inset 0 2px 3px oklch(0.3 0.03 220 / 0.18)', + 'inset 0 -1px 0 oklch(0.98 0.03 220 / 0.65)', + ].join(', '), resting: [ '0 2px 0 oklch(0.3 0.03 220 / 0.28)', '0 3px 5px -1px oklch(0.3 0.03 220 / 0.16)', @@ -114,7 +120,7 @@ export const elmoFoundation: ThemeFoundation = { '0 2px 6px oklch(0.12 0.01 250 / 0.18)', '0 1px 3px oklch(0.12 0.01 250 / 0.12)', ].join(', '), - recessed: 'none', + recessed: 'inset 0 1px 2px oklch(0.12 0.01 250 / 0.22)', resting: [ '0 1px 3px oklch(0.12 0.01 250 / 0.12)', '0 1px 2px oklch(0.12 0.01 250 / 0.06)', @@ -148,7 +154,7 @@ export const elmoFoundation: ThemeFoundation = { '0 2px 6px oklch(0.2 0.01 250 / 0.05)', '0 1px 3px oklch(0.2 0.01 250 / 0.035)', ].join(', '), - recessed: 'none', + recessed: 'inset 0 1px 2px oklch(0.2 0.01 250 / 0.06)', resting: [ '0 1px 3px oklch(0.2 0.01 250 / 0.04)', '0 1px 2px oklch(0.2 0.01 250 / 0.02)',