From a0b7767a7bb05ed9842c8bbef052b68519b536bb Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Thu, 15 Oct 2026 14:07:05 -0600 Subject: [PATCH 1/2] feat(ui): enlarge checkbox hit area and add hover state Checkboxes take clicks within 6px of the visible box, giving the small list checkbox a 24px target. Hovering the label, box, or surrounding area strengthens the border or shifts the checked fill; disabled and invalid controls are unchanged. --- .../ui/list/ListItem.browser.test.tsx | 28 ++++ .../ui/theme/CheckboxHover.browser.test.tsx | 150 ++++++++++++++++++ .../webv2/src/platform/ui/theme/recipes.ts | 45 ++++++ .../webv2/src/platform/ui/theme/system.ts | 2 + 4 files changed, 225 insertions(+) create mode 100644 invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx diff --git a/invokeai/frontend/webv2/src/platform/ui/list/ListItem.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/list/ListItem.browser.test.tsx index 10bfc776717..2d02e01a5ce 100644 --- a/invokeai/frontend/webv2/src/platform/ui/list/ListItem.browser.test.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/list/ListItem.browser.test.tsx @@ -139,6 +139,34 @@ describe('ListItem', () => { expect(host.querySelector('[data-list-primary] [title]')!.children).toHaveLength(2); }); + it('toggles the checkbox from the padding around it without stealing presses from the primary button', async () => { + const onCheckedChange = vi.fn(); + const onPress = vi.fn(); + const item = (isChecked: boolean) => ( + + ); + + await render(item(false)); + + const row = primary().parentElement!; + const rowRect = row.getBoundingClientRect(); + const control = host.querySelector('[data-part="control"]')!.getBoundingClientRect(); + const middle = control.top + control.height / 2 - rowRect.top; + + // The row's start padding and the space above the control, both outside its visible box. + await userEvent.click(row, { position: { x: control.left - rowRect.left - 4, y: middle } }); + expect(onCheckedChange).toHaveBeenLastCalledWith(true); + await render(item(true)); + await userEvent.click(row, { position: { x: control.left - rowRect.left + 2, y: control.top - rowRect.top - 4 } }); + expect(onCheckedChange).toHaveBeenLastCalledWith(false); + expect(onPress).not.toHaveBeenCalled(); + + // Past the outset, the primary button's start padding still belongs to the press. + await userEvent.click(row, { position: { x: control.right - rowRect.left + 8, y: middle } }); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onCheckedChange).toHaveBeenCalledTimes(2); + }); + it('keeps a title with line breaks on one line', async () => { await render( undefined} />); const singleLineHeight = host diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx new file mode 100644 index 00000000000..62345f22fa0 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx @@ -0,0 +1,150 @@ +import { Box, Checkbox, ChakraProvider } from '@chakra-ui/react'; +import { applyThemeToRoot } from '@theme/applyTheme'; +import { compositeColors, getContrastRatio } from '@theme/contrastRatio.testing'; +import { system } from '@theme/system'; +import { THEMES } from '@theme/themes'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; + +/** + * Hovering anywhere in a checkbox's target (label, control, or the invisible outset around the control) must read as + * a stronger boundary or a shifted fill in every theme, while a disabled checkbox stays as it rests. + */ + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + // Read settled colors: reduced motion shortens the hover transition to a millisecond. + document.documentElement.dataset.reduceMotion = 'true'; +}); + +afterEach(async () => { + await userEvent.unhover(document.body); + await act(() => root?.unmount()); + host?.remove(); + delete document.documentElement.dataset.highContrast; + delete document.documentElement.dataset.reduceMotion; + applyThemeToRoot('classic'); + host = null; + root = null; +}); + +const SAMPLES = [ + { id: 'unchecked', checked: false, disabled: false, invalid: false }, + { id: 'checked', checked: true, disabled: false, invalid: false }, + { id: 'disabled', checked: false, disabled: true, invalid: false }, + { id: 'invalid', checked: false, disabled: false, invalid: true }, +] as const; + +const renderCheckboxes = async () => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(() => { + root?.render( + + + {SAMPLES.map(({ checked, disabled, id, invalid }) => ( + + + + {id} + + ))} + + + ); + }); + + const panel = getComputedStyle(host.firstElementChild!).backgroundColor; + const part = (id: string, name: string) => + host!.querySelector(`[data-testid="${id}"] [data-part="${name}"]`)!; + const settle = () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + // A point in the outset: left of the control's visible box, level with its middle. + const hoverOutset = async (id: string) => { + const label = host!.querySelector(`[data-testid="${id}"]`)!; + const labelRect = label.getBoundingClientRect(); + const control = part(id, 'control').getBoundingClientRect(); + + await userEvent.hover(label, { + position: { x: control.left - labelRect.left - 4, y: control.top - labelRect.top + control.height / 2 }, + }); + await settle(); + }; + const hoverLabel = async (id: string) => { + await userEvent.hover(part(id, 'label')); + await settle(); + }; + const read = (id: string) => { + const style = getComputedStyle(part(id, 'control')); + const fill = compositeColors(style.backgroundColor, panel); + + return { + border: getContrastRatio(style.borderTopColor, panel, 1), + fill, + mark: getContrastRatio(style.color, fill, 1), + }; + }; + + return { hoverLabel, hoverOutset, read }; +}; + +describe('checkbox hover', () => { + for (const theme of THEMES) { + for (const highContrast of [false, true]) { + it(`strengthens the boundary and shifts the fill on ${theme.id}${highContrast ? ' (high contrast)' : ''}`, async () => { + applyThemeToRoot(theme.id); + if (highContrast) { + document.documentElement.dataset.highContrast = 'true'; + } + const { hoverOutset, read } = await renderCheckboxes(); + const unchecked = read('unchecked'); + const checked = read('checked'); + + await hoverOutset('unchecked'); + expect(read('unchecked').border).toBeGreaterThan(unchecked.border * 1.1); + + await hoverOutset('checked'); + const hoveredChecked = read('checked'); + + expect(hoveredChecked.fill).not.toBe(checked.fill); + // The check stays a legible graphic on the hovered fill. + expect(hoveredChecked.mark).toBeGreaterThanOrEqual(3); + }); + } + } + + it('shows the hover from the label, not on a disabled checkbox, and keeps an invalid one in error', async () => { + const { hoverLabel, hoverOutset, read } = await renderCheckboxes(); + const unchecked = read('unchecked'); + const disabled = read('disabled'); + + await hoverLabel('unchecked'); + expect(read('unchecked').border).toBeGreaterThan(unchecked.border); + + await hoverOutset('disabled'); + expect(read('disabled')).toEqual(disabled); + await hoverLabel('disabled'); + expect(read('disabled')).toEqual(disabled); + + const invalid = read('invalid'); + + await hoverLabel('invalid'); + expect(read('invalid')).toEqual(invalid); + }); +}); diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts b/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts index 451fb9f0c13..8dde410e328 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts @@ -715,6 +715,51 @@ export const scrollAreaSlotRecipe = defineSlotRecipe({ }, }); +/** + * Extend the control's hit area past its box with an invisible outset; the root is a label, so a press there toggles + * the input. 6px gives the smallest (sm, 12px) control a 24px target and stays under the 8px minimum spacing between + * neighbouring controls, so it never reaches a neighbour's box. Insets resolve from the padding box, so the outset + * also clears the control's 1px border. + * + * Ark marks the control hovered while any part of the root is, so the label and the outset show the hover too. The + * hover is defined for the `solid` variant only, the default every checkbox uses. + */ +export const checkboxSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.checkbox, + base: { + ...chakraSlotRecipes.checkbox.base, + control: { + ...chakraSlotRecipes.checkbox.base?.control, + position: 'relative', + transitionDuration: 'fast', + transitionProperty: 'border-color, background', + _before: { content: '""', inset: 'calc(-1px - {spacing.1.5})', position: 'absolute' }, + }, + }, + variants: { + ...chakraSlotRecipes.checkbox.variants, + variant: { + ...chakraSlotRecipes.checkbox.variants?.variant, + solid: { + control: { + ...chakraSlotRecipes.checkbox.variants?.variant?.solid?.control, + _hover: { + // Halfway to the foreground is stronger than the rest border in every theme, high contrast included, + // where the rest border already matches the muted foreground. An invalid control keeps its error border. + '&[data-state=unchecked]:not([data-invalid])': { + borderColor: 'color-mix(in oklab, {colors.border.emphasized}, {colors.fg})', + }, + '&:is([data-state=checked], [data-state=indeterminate])': { + bg: 'colorPalette.solid/90', + borderColor: 'colorPalette.solid/90', + }, + }, + }, + }, + }, + }, +}); + export const sliderSlotRecipe = defineSlotRecipe({ ...chakraSlotRecipes.slider, base: { diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/system.ts b/invokeai/frontend/webv2/src/platform/ui/theme/system.ts index 3f7d67fc6b1..e995564c06b 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/system.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/system.ts @@ -3,6 +3,7 @@ import { createSystem, defineConfig } from '@chakra-ui/react'; import { baseConfig } from './rebase'; import { buttonRecipe, + checkboxSlotRecipe, colorPickerSlotRecipe, comboboxSlotRecipe, dataListSlotRecipe, @@ -387,6 +388,7 @@ const config = defineConfig({ textarea: textareaRecipe, }, slotRecipes: { + checkbox: checkboxSlotRecipe, colorPicker: colorPickerSlotRecipe, combobox: comboboxSlotRecipe, dataList: dataListSlotRecipe, From 267d6d29509992582b7914dba8b38d19aa1d797d Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 17 Oct 2026 01:44:06 -0600 Subject: [PATCH 2/2] fix(ui): preserve invalid checkbox hover colors Exclude invalid checked and indeterminate controls from hover styling. Cover error appearance when hovering labels and surrounding hit areas. --- .../ui/theme/CheckboxHover.browser.test.tsx | 20 ++++++++++++++----- .../webv2/src/platform/ui/theme/recipes.ts | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx index 62345f22fa0..575ba89244b 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/theme/CheckboxHover.browser.test.tsx @@ -38,6 +38,8 @@ const SAMPLES = [ { id: 'checked', checked: true, disabled: false, invalid: false }, { id: 'disabled', checked: false, disabled: true, invalid: false }, { id: 'invalid', checked: false, disabled: false, invalid: true }, + { id: 'invalid-checked', checked: true, disabled: false, invalid: true }, + { id: 'invalid-indeterminate', checked: 'indeterminate', disabled: false, invalid: true }, ] as const; const renderCheckboxes = async () => { @@ -129,7 +131,7 @@ describe('checkbox hover', () => { } } - it('shows the hover from the label, not on a disabled checkbox, and keeps an invalid one in error', async () => { + it('shows the hover from the label, not on a disabled checkbox', async () => { const { hoverLabel, hoverOutset, read } = await renderCheckboxes(); const unchecked = read('unchecked'); const disabled = read('disabled'); @@ -141,10 +143,18 @@ describe('checkbox hover', () => { expect(read('disabled')).toEqual(disabled); await hoverLabel('disabled'); expect(read('disabled')).toEqual(disabled); + }); - const invalid = read('invalid'); + it.each(['invalid', 'invalid-checked', 'invalid-indeterminate'])( + 'preserves the error appearance of %s when hovering the label or outset', + async (id) => { + const { hoverLabel, hoverOutset, read } = await renderCheckboxes(); + const invalid = read(id); - await hoverLabel('invalid'); - expect(read('invalid')).toEqual(invalid); - }); + await hoverLabel(id); + expect(read(id)).toEqual(invalid); + await hoverOutset(id); + expect(read(id)).toEqual(invalid); + } + ); }); diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts b/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts index 8dde410e328..e2e9a60476d 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts @@ -749,7 +749,7 @@ export const checkboxSlotRecipe = defineSlotRecipe({ '&[data-state=unchecked]:not([data-invalid])': { borderColor: 'color-mix(in oklab, {colors.border.emphasized}, {colors.fg})', }, - '&:is([data-state=checked], [data-state=indeterminate])': { + '&:is([data-state=checked], [data-state=indeterminate]):not([data-invalid])': { bg: 'colorPalette.solid/90', borderColor: 'colorPalette.solid/90', },