/** * The global focus outline in app/accessibility.css. * * jsdom does not apply stylesheets or match :focus-visible, so the test takes * the selectors of the outline rule, drops the focus pseudo-class and checks * which elements the rest of each selector matches. */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import path from 'path'; const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8').replace(/\r\n/g, '\n'); /** Selectors of the first top-level rule whose body contains `declaration`. */ function selectorsOf(declaration: string): string[] { const at = css.indexOf(declaration); expect(at).toBeGreaterThan(-1); const open = css.lastIndexOf('{', at); const prevClose = css.lastIndexOf('}', open); const prelude = css.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, ''); // Split on top-level commas only (the :not() lists have commas too). const out: string[] = []; let depth = 0; let cur = ''; for (const ch of prelude) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; } else cur += ch; } out.push(cur.trim()); return out.filter(Boolean); } const outlined = selectorsOf('outline: var(--focus-ring-width) solid var(--focus-ring-color)') .find((s) => s.startsWith(':focus-visible'))! .replace(/^:focus-visible/, '*'); function el(html: string): Element { const host = document.createElement('div'); host.innerHTML = html; return host.firstElementChild!; } describe('global focus outline', () => { it.each([ ['a menu item', '
Reply
'], ['a listbox option', '
Inbox
'], ['a grid cell', '
12
'], ['a tab', ''], ['a row', '
'], ['a tree item', '
Work
'], ['a button in a toolbar', ''], ['a link', 'Open'], ['a plain button', ''], ])('draws on %s, even with tabindex -1', (_name, html) => { expect(el(html).matches(outlined)).toBe(true); }); it.each([ ['a message list container', '
'], ['a dialog', '
'], ['main', '
'], ['a heading focused after navigation', '

Inbox

'], ['a menu container', '
'], ])('skips %s that only takes focus from script', (_name, html) => { expect(el(html).matches(outlined)).toBe(false); }); it('takes its colour from the ring token, which themes keep at 3:1', () => { expect(css).toMatch(/--focus-ring-color:\s*var\(--color-ring\)/); }); }); /** The body of the top-level `@media (forced-colors: active)` block. */ function forcedBlock(): string { const at = css.indexOf('@media (forced-colors: active) {'); expect(at).toBeGreaterThan(-1); const open = css.indexOf('{', at); let depth = 0; let end = open; for (; end < css.length; end++) { if (css[end] === '{') depth++; if (css[end] === '}' && --depth === 0) break; } return css.slice(open + 1, end).replace(/\/\*[\s\S]*?\*\//g, ''); } /** The selector of the rule in `block` whose body contains `declaration`. */ function ruleSelector(block: string, declaration: string): string { const at = block.indexOf(declaration); expect(at).toBeGreaterThan(-1); const open = block.lastIndexOf('{', at); return block.slice(block.lastIndexOf('}', open) + 1, open).trim(); } describe('forced colours', () => { const block = forcedBlock(); const selected = ruleSelector(block, 'background-color: Highlight !important;\n color: HighlightText'); it.each([ ['a pressed segment (settings radio group, formatting toggle)', ''], ['the selected tab', ''], ['the open message row', '
'], ['the current folder or page', ''], ['an active toolbar button', ''], ['a checked view-mode radio', ''], ['a selected option', '
'], ])('maps %s to Highlight', (_name, html) => { expect(el(html).matches(selected)).toBe(true); }); it.each([ ['an unpressed segment', ''], ['an unselected tab', ''], ['today without selection', ''], ['a native radio', ''], ])('leaves %s alone', (_name, html) => { expect(el(html).matches(selected)).toBe(false); }); it('gives switches a border and a knob that keeps its fill', () => { expect(ruleSelector(block, 'border: 1px solid ButtonText')).toBe('[role="switch"]'); const knob = block.slice(block.indexOf('[role="switch"] > span')); expect(knob).toMatch(/^\[role="switch"\] > span \{\s*forced-color-adjust: none;\s*background-color: ButtonText/); expect(block).toMatch(/\[role="switch"\]\[aria-checked="true"\] > span \{\s*background-color: HighlightText/); }); it('lets a colour swatch keep its colour, and only the swatch', () => { expect(el('').matches(':is(span, div, i):empty[style*="background"]')).toBe(true); expect(block).toContain(':is(span, div, i):empty[style*="background"] {\n forced-color-adjust: none;'); }); }); describe('large targets', () => { const tall = selectorsOf('min-height: 44px;').join(', '); const wide = selectorsOf('min-width: 44px;').join(', '); const strip = (sel: string) => sel.replace(/:root\[data-large-targets="on"\]\s*/g, ''); it.each([ ['a button', ''], ['a menu item', '
Archive
'], ['an option', '
Inbox
'], ['a tab', ''], ['a custom checkbox', ''], ])('makes %s 44px tall when On', (_name, html) => { expect(el(html).matches(strip(tall))).toBe(true); }); it('keeps the switch at its drawn size and gives it a 44px hit area instead', () => { const sw = el(''); expect(sw.matches(strip(tall))).toBe(false); expect(sw.matches(strip(wide))).toBe(false); expect(css).toMatch(/:root\[data-large-targets="on"\] \[role="switch"\]::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); }); it('applies only when the setting is On; System is left to the touch-screen styles', () => { for (const s of selectorsOf('min-height: 44px;')) expect(s.startsWith(':root[data-large-targets="on"]')).toBe(true); }); }); describe('large text in narrow panes', () => { it('lets labels in the folder list and settings navigation wrap once the pane is narrow for the text', () => { expect(selectorsOf('container: a11y-nav / inline-size;')).toEqual(['nav:is([data-tour="sidebar"], [data-tour="settings-tabs"])']); expect(css).toMatch(/@container a11y-nav \(width < 14rem\) \{\s*\.truncate \{[^}]*white-space: normal;/); }); it('shows a cut label in full while its control has the keyboard focus', () => { const wrap = selectorsOf('hyphens: auto;\n}').map((s) => s.replace(':focus-visible', '')); const label = el('').querySelector('.truncate')!; document.body.append(label.parentElement!); expect(wrap.some((s) => label.matches(s))).toBe(true); const rowLabel = el('
Subject
').querySelector('.truncate')!; document.body.append(rowLabel.parentElement!); expect(wrap.some((s) => rowLabel.matches(s))).toBe(false); }); }); describe('scroll padding', () => { it('keeps a focused element clear of sticky headers inside the scrolling panes too', () => { const panes = selectorsOf('scroll-padding-block: 3rem;').join(', '); expect(el('
').matches(panes)).toBe(true); expect(el('
').matches(panes)).toBe(true); }); }); describe('reduced transparency', () => { it('gives Aurora Glass solid surfaces, in both colour schemes', () => { expect(css).toMatch(/:root\[data-transparency="reduce"\] body\[data-theme-skin="builtin-aurora-glass"\] \{\s*--color-sidebar-accent: #e1e2fb;/); expect(css).toMatch(/:root\.dark\[data-transparency="reduce"\] body\[data-theme-skin="builtin-aurora-glass"\] \{[^}]*--color-card: #101119;/); expect(css).toMatch(/:root\[data-transparency="reduce"\] body\[data-theme-skin="builtin-aurora-glass"\] :is\(\.bg-card, \.bg-popover, \[role="menu"\], \[role="dialog"\], \[role="listbox"\]\) \{\s*background-color: var\(--color-popover\) !important;/); }); });