From 8f9fb9c50084d7b8f13ceb355c954dd37c788b20 Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 11 Nov 2026 18:01:42 +0100 Subject: [PATCH 1/9] feat(elements): make the hover effect of buttons and result rows clearer The buttons get a 2 px edge on hover and when pressed, "Clear filters" turns to the danger color, and the text shadow of the rows is replaced by a 4 px edge on the first cell. An e2e test measures the edge and the danger color in both themes. Closes #171 --- docs/AI_USAGE.md | 7 ++ e2e/hover.e2e.ts | 80 +++++++++++++++++++ src/components/elements/ResultsTable.tsx | 12 +-- src/pages/ElementsPage.tsx | 16 +++- .../components/elements/ResultsTable.test.tsx | 12 +-- 5 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 e2e/hover.e2e.ts diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 44e324f..a8a16e5 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -596,3 +596,10 @@ - **What the team did:** Took the peer feedback that the README (about 59 kB) is too long to find your way in, wrote issue #182, and asked for the reasoning to be kept. - **What Claude did:** Moved four long sections unchanged to their own documents (`ELEMENTS_PAGE.md`, `DATABASE_SETUP.md`, `CATALOG_SEARCH.md`, `E2E_TESTS.md`) with a short summary and a link in the README (now about half the size). Added a "Where is what" table that points from each feature to the file where it starts, and a list of contents at the top of `docs/API.md`. A script checked that every relative link and anchor in the changed files works. - **Limits:** The text of the moved sections was not shortened or checked again, so what was outdated in them is still outdated (that is issue #148). A first run of the move emptied `README.md` because a formatting command was cut off in a pipe; it was restored from git and run again. + +## The hover state of the buttons and the rows (`src/pages/ElementsPage.tsx`, `src/components/elements/ResultsTable.tsx`) + +- **Tool:** Claude Code +- **What the team did:** Took the peer feedback that the hover effect of the buttons is hard to see, that the thin text shadow of the result rows can be blended differently by each browser, and that "Clear filters" could have a danger color (issue #171). +- **What Claude did:** Measured the hover states in a real browser in both themes first (the button color changed by only 1.09:1 in light mode). Gave the buttons a 2 px edge in the text color on hover and when pressed (12.8:1 against the page in light and 7.4:1 in dark), made "Clear filters" turn to `--danger` with `--danger-contrast` (6.6:1 in light and 8.5:1 in dark), and replaced the text shadow of the rows by a 4 px edge on the first cell. An end-to-end test (`e2e/hover.e2e.ts`) measures the edge and the danger color in both themes. +- **Limits:** Measured on one machine in Chromium only, so how other browsers draw the edge is not checked. The rows still have no color change in light mode (the design has none); only the edge and the height show the hover there. diff --git a/e2e/hover.e2e.ts b/e2e/hover.e2e.ts new file mode 100644 index 0000000..ec68f61 --- /dev/null +++ b/e2e/hover.e2e.ts @@ -0,0 +1,80 @@ +// The hover state of the buttons and of the result rows, measured in a real browser in both +// themes: the hovered state must be easy to tell from the resting one (an edge of at least 3:1 +// against the page, WCAG 1.4.11), and "Clear filters" turns to the danger color. +import { expect, test, type Page } from '@playwright/test' +import { chooseTheme, type Theme } from './gymnast-helpers.ts' +import { choose, openMenu, resultsLoaded } from './helpers.ts' + +/** The contrast ratio of two CSS colors, computed in the page (so every color syntax works). */ +async function contrast(page: Page, a: string, b: string) { + return page.evaluate( + ([x, y]) => { + const rgb = (color: string) => { + const context = document.createElement('canvas').getContext('2d')! + context.fillStyle = color + context.fillRect(0, 0, 1, 1) + return [...context.getImageData(0, 0, 1, 1).data].slice(0, 3) + } + const luminance = (color: string) => { + const [r, g, b] = rgb(color).map((v) => { + const c = v! / 255 + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b! + } + const [hi, lo] = [luminance(x!), luminance(y!)].sort((p, q) => q - p) + return (hi! + 0.05) / (lo! + 0.05) + }, + [a, b], + ) +} + +for (const theme of ['light', 'dark'] as Theme[]) { + test(`the hovered button has an edge of 3:1 and Clear filters turns red (${theme})`, async ({ + page, + }) => { + await chooseTheme(page, theme) + await page.goto('elements') + await resultsLoaded(page) + const pageBackground = await page + .locator('main') + .evaluate((e) => getComputedStyle(e).backgroundColor) + + const button = page.getByRole('button', { name: /Apparatus/ }).first() + await page.mouse.move(0, 0) + const resting = await button.evaluate((e) => getComputedStyle(e).boxShadow) + await button.hover() + // The edge is a 2 px ring in the text color. It is not there at rest, and the + // transition has to be over before it is measured. + const RING = /(rgba?[(][^)]*[)]|color[(][^)]*[)]) 0px 0px 0px 2px/ + await expect + .poll(async () => + RING.exec(await button.evaluate((e) => getComputedStyle(e).boxShadow)), + ) + .not.toBeNull() + expect(resting).not.toMatch(RING) + const ring = RING.exec( + await button.evaluate((e) => getComputedStyle(e).boxShadow), + ) + expect(await contrast(page, ring![1]!, pageBackground)).toBeGreaterThan(3) + + await openMenu(page, /Apparatus/) + await choose(page, 'menuitemradio', 'Vault') + await page.keyboard.press('Escape') + const clear = page.getByRole('button', { name: 'Clear filters' }) + await expect(clear).toBeVisible() + // The danger color of the theme, as the browser writes it. + const danger = await page.evaluate(() => { + const probe = document.createElement('span') + probe.style.color = 'var(--danger)' + document.body.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + }) + await clear.hover() + await expect + .poll(() => clear.evaluate((e) => getComputedStyle(e).backgroundColor)) + .toBe(danger) + }) +} diff --git a/src/components/elements/ResultsTable.tsx b/src/components/elements/ResultsTable.tsx index 8ff38d8..8667df9 100644 --- a/src/components/elements/ResultsTable.tsx +++ b/src/components/elements/ResultsTable.tsx @@ -113,12 +113,14 @@ const COLUMN_HEADER = // The whole row is one link, so the whole row answers when the pointer or the // keyboard is on it: it gets taller (5 px above and below, in front of the next // rows) and, in dark mode, a little lighter. Only the height changes, so nothing -// moves and the table does not get wider. A shadow does it. The text looks only -// a little bolder (a very thin shadow in the color of the text): real bold makes -// the words wider and moves the line breaks, and more than a little makes the -// text harder to read. (The classes are written out, so Tailwind can find them.) +// moves and the table does not get wider. A shadow does it. The first cell also gets a +// 4 px edge in the text color on its left side, so the row can be told from the others in +// light mode too, where its color does not change. The text itself is not changed: a thin +// shadow around the letters (to make them look bolder) is blended differently by each +// browser, and real bold makes the words wider and moves the line breaks. (The classes +// are written out, so Tailwind can find them.) const ROW_EFFECT = - 'group relative cursor-pointer [--row-bg:var(--surface)] even:[--row-bg:var(--table-row-alt)] [--row-hover:color-mix(in_srgb,var(--row-bg),var(--text-color)_var(--row-hover-amount))] hover:*:bg-(--row-hover) focus-within:*:bg-(--row-hover) not-last:hover:bg-(--row-hover) not-last:focus-within:bg-(--row-hover) hover:z-10 focus-within:z-10 hover:*:shadow-[0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] focus-within:*:shadow-[0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] hover:[text-shadow:0.15px_0_0_currentColor,-0.15px_0_0_currentColor] focus-within:[text-shadow:0.15px_0_0_currentColor,-0.15px_0_0_currentColor] *:transition-[box-shadow,background-color] *:duration-150 motion-reduce:*:transition-none focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-(--text-color)' + 'group relative cursor-pointer [--row-bg:var(--surface)] even:[--row-bg:var(--table-row-alt)] [--row-hover:color-mix(in_srgb,var(--row-bg),var(--text-color)_var(--row-hover-amount))] hover:*:bg-(--row-hover) focus-within:*:bg-(--row-hover) not-last:hover:bg-(--row-hover) not-last:focus-within:bg-(--row-hover) hover:z-10 focus-within:z-10 hover:*:shadow-[0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] focus-within:*:shadow-[0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] hover:*:first:shadow-[inset_4px_0_0_0_var(--text-color),0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] focus-within:*:first:shadow-[inset_4px_0_0_0_var(--text-color),0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] *:transition-[box-shadow,background-color] *:duration-150 motion-reduce:*:transition-none focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-(--text-color)' // The colors are on the cells (so the corners can be round). const ROW_LOOK = diff --git a/src/pages/ElementsPage.tsx b/src/pages/ElementsPage.tsx index 4add436..2f17ed6 100644 --- a/src/pages/ElementsPage.tsx +++ b/src/pages/ElementsPage.tsx @@ -44,8 +44,18 @@ import { type Sort, } from '@/lib/searchOperations' -const PILL = - 'h-10 rounded-full border-0 bg-(--button-bg) px-4 text-sm font-medium text-(--button-text) shadow-(--box-shadow) transition-colors hover:bg-(--button-hover-bg) focus-visible:bg-(--button-hover-bg) dark:border-transparent dark:bg-(--button-bg) dark:hover:bg-(--button-hover-bg) hover:text-(--button-text) dark:hover:text-(--button-text) focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) motion-reduce:transition-none' +// The buttons of the page. On hover (and when pressed, which a phone needs, because Tailwind 4 +// only uses `hover:` where the device can hover) the button gets a 2 px edge in the text color +// and a new color, so the change is easy to see in both themes. When pressed it also gets a +// little smaller (not with reduced motion). +const PILL_BASE = + 'h-10 rounded-full border-0 bg-(--button-bg) px-4 text-sm font-medium text-(--button-text) shadow-(--box-shadow) transition-[background-color,color,box-shadow,transform] hover:bg-(--button-hover-bg) focus-visible:bg-(--button-hover-bg) active:bg-(--button-hover-bg) dark:border-transparent dark:bg-(--button-bg) dark:hover:bg-(--button-hover-bg) dark:active:bg-(--button-hover-bg) hover:ring-2 active:ring-2 focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) motion-reduce:transition-none motion-safe:active:scale-[0.97]' + +const PILL = `${PILL_BASE} hover:text-(--button-text) active:text-(--button-text) dark:hover:text-(--button-text) dark:active:text-(--button-text) hover:ring-(--text-color) active:ring-(--text-color)` + +// "Clear filters" turns to the danger color on hover and when pressed, as the trash icon of the +// Gymnast page does. +const PILL_DANGER = `${PILL_BASE} hover:bg-(--danger) active:bg-(--danger) dark:hover:bg-(--danger) dark:active:bg-(--danger) hover:text-(--danger-contrast) active:text-(--danger-contrast) dark:hover:text-(--danger-contrast) dark:active:text-(--danger-contrast) hover:ring-(--danger) active:ring-(--danger)` export default function ElementsPage() { const [searchParams, setSearchParams] = useSearchParams() @@ -314,7 +324,7 @@ export default function ElementsPage() {