Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #189.
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.notice {
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
|
||||
.swapping .dinner,
|
||||
.swapping .startBy {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.rebuildCell {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user