Accessibility pass (#189)
CI / ci (push) Failing after 6s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #189.
This commit is contained in:
2026-10-11 00:04:22 -04:00
committed by approver_agent
parent d390d8dcce
commit 759fbbe3dd
11 changed files with 255 additions and 7 deletions
+86
View File
@@ -0,0 +1,86 @@
// Text contrast over the design tokens (docs/A11Y.md, "Text contrast"). Every
// colour a stylesheet gives text must be a token listed in TEXT_GROUNDS, and
// every pair listed there must reach WCAG AA's 4.5:1 for normal-size text.
import { describe, expect, it } from 'vitest'
import tokens from './styles/tokens.css?raw'
const stylesheets = import.meta.glob<string>(['./**/*.css', '!./styles/tokens.css'], {
query: '?raw',
import: 'default',
eager: true,
})
// The grounds MealQuest's pages, cards, rows and tinted fills use.
const LIGHT_GROUNDS = [
'--color-bg',
'--color-surface',
'--color-neutral-100',
'--color-neutral-200',
'--color-accent-100',
'--color-accent-200',
'--color-accent-2-100',
'--color-accent-2-200',
]
// Each text colour token, with every ground it is drawn on.
const TEXT_GROUNDS: Record<string, string[]> = {
'--color-text': [...LIGHT_GROUNDS, '--color-accent-2-300'],
'--color-neutral-700': LIGHT_GROUNDS,
'--color-neutral-800': LIGHT_GROUNDS,
'--color-neutral-900': LIGHT_GROUNDS,
'--color-accent': LIGHT_GROUNDS,
'--color-accent-700': LIGHT_GROUNDS,
'--color-accent-800': LIGHT_GROUNDS,
'--color-accent-2-800': LIGHT_GROUNDS,
'--color-accent-2-900': [...LIGHT_GROUNDS, '--color-accent-2-300'],
// Light ink on the grape fills (buttons, the active nav pill, the chosen
// text size) and on the dark ones (the Offline pill).
'--on-accent': ['--color-accent', '--color-accent-800', '--color-neutral-800'],
// The dark pill button and the chosen settings tab.
'--color-bg': ['--color-text', '--color-neutral-800'],
// The bottom bar's inactive items, on the dark bar.
'--color-neutral-400': ['--color-text'],
}
function hex(name: string): string {
const value = new RegExp(`^\\s*${name}:\\s*(#[0-9a-fA-F]{6})\\b`, 'm').exec(tokens)?.[1]
if (value === undefined) throw new Error(`${name} is not a hex colour in tokens.css`)
return value
}
// Relative luminance, per WCAG 2.
function luminance(colour: string): number {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = Number.parseInt(colour.slice(i, i + 2), 16) / 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!
}
function contrast(a: string, b: string): number {
const [light, dark] = [luminance(hex(a)), luminance(hex(b))].sort((x, y) => y - x)
return (light! + 0.05) / (dark! + 0.05)
}
describe('text contrast', () => {
const pairs = Object.entries(TEXT_GROUNDS).flatMap(([text, grounds]) => grounds.map((ground) => [text, ground] as const))
it.each(pairs)('%s on %s reaches 4.5:1', (text, ground) => {
expect(contrast(text, ground)).toBeGreaterThanOrEqual(4.5)
})
it('gives text only the colour tokens checked above', () => {
expect(Object.keys(stylesheets).length).toBeGreaterThan(0)
const unchecked = Object.entries(stylesheets).flatMap(([path, css]) =>
Array.from(css.matchAll(/(?<![\w-])color:\s*([^;}]+)/g))
.map((match) => match[1]!.trim())
.filter((value) => value !== 'inherit')
.filter((value) => {
const token = /^var\((--[\w-]+)\)$/.exec(value)?.[1]
return token === undefined || !(token in TEXT_GROUNDS)
})
.map((value) => `${path}: color: ${value}`),
)
expect(unchecked).toEqual([])
})
})
+9 -2
View File
@@ -341,6 +341,13 @@
box-shadow: none;
}
/* The pill clips anything outside it, so the focus ring sits inside the
size, drawn in on-accent ink: only the chosen size takes focus. */
.readingHeader .textSize > :focus-visible {
outline-color: var(--on-accent);
outline-offset: -4px;
}
.readingHeader .textSize > :nth-child(1) {
font-size: 13px;
}
@@ -433,7 +440,7 @@
}
.setOut {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
text-decoration: line-through;
}
@@ -515,7 +522,7 @@
}
.stepDone {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
}
.stepCurrent {
+16 -1
View File
@@ -325,7 +325,7 @@ describe('Kitchen reading mode', () => {
// The step done is muted, its number a check.
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
@@ -381,6 +381,21 @@ describe('Kitchen reading mode', () => {
})
describe('text size', () => {
it('rings the focused size inside the clipped pill, in on-accent ink on the grape', async () => {
serve()
await renderPage()
const medium = screen.getByRole('radio', { name: 'Medium text' })
// A key press makes the next focus a keyboard one, which
// :focus-visible matches.
fireEvent.keyDown(document.body, { key: 'Tab' })
medium.focus()
expect(medium.matches(':focus-visible')).toBe(true)
// The pill hides overflow, so a ring outside the size would be cut off.
expect(declaredStyle(medium).getPropertyValue('outline-offset')).toBe('-4px')
expect(declaredStyle(medium).getPropertyValue('outline-color')).toBe('var(--on-accent)')
expect(declaredStyle(medium).background).toBe('var(--color-accent)')
})
it('is remembered on this device', async () => {
serve()
const view = await renderPage()
+1 -1
View File
@@ -71,7 +71,7 @@
}
.input::placeholder {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
}
.notice {
+1 -1
View File
@@ -203,7 +203,7 @@
.swapping .dinner,
.swapping .startBy {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
}
.rebuildCell {
+5 -1
View File
@@ -8,6 +8,7 @@ import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
@@ -1222,7 +1223,7 @@ describe('PlanPage', () => {
expect(declaredStyle(friday).background).toBe('var(--color-bg)')
expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)')
expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)')
// "Quest log · N | Regulars · N", on the Quest log first.
expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
@@ -1644,6 +1645,9 @@ describe.each([
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
expect(chip).toHaveTextContent('Lemon orzo')
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
// The 28 px photo makes a 38 px chip; the chip is a button, so it
// keeps the 44 px tap minimum.
expectMinHitArea(within(chip).getByRole('button', { name: /Lemon orzo/ }))
// The strip follows the grid.
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+5
View File
@@ -190,7 +190,12 @@
padding: 8px;
}
/* The 28 px photo makes a 38 px chip; the chip is a button, so it keeps the
44 px tap minimum. */
.chip.draggable {
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
padding: 3px 12px 3px 3px;
}
+1 -1
View File
@@ -40,7 +40,7 @@
/* A dinner already this week: shown, muted, and not a button. */
.already {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
}
.skeletonRow {
+6
View File
@@ -48,6 +48,12 @@
color: var(--on-accent);
}
/* The app-wide grape focus ring barely shows on the dark bar (1.8:1); the
bar's ring is the light on-accent ink instead. */
.item:focus-visible {
outline-color: var(--on-accent);
}
.label {
font-family: var(--font-body);
font-size: 13px;
+12
View File
@@ -182,6 +182,18 @@ describe('BottomBar', () => {
])
})
it('rings a focused item in on-accent ink, which shows on the dark bar', async () => {
await renderAt('/')
const item = link('Plan')
// A key press makes the next focus a keyboard one, which :focus-visible
// matches; earlier tests' clicks would leave it a pointer one.
fireEvent.keyDown(document.body, { key: 'Tab' })
item.focus()
expect(item).toHaveFocus()
expect(item.matches(':focus-visible')).toBe(true)
expect(declaredStyle(item).getPropertyValue('outline-color')).toBe('var(--on-accent)')
})
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
fireEvent.click(link('Plan'))