diff --git a/e2e/specs/layout-overflow.spec.ts b/e2e/specs/layout-overflow.spec.ts index 03f9b03..5819dfe 100644 --- a/e2e/specs/layout-overflow.spec.ts +++ b/e2e/specs/layout-overflow.spec.ts @@ -135,3 +135,26 @@ test.describe('the app fits in its own window', () => { expect(reachable).toBe(true); }); }); + +test.describe('the title block fits its bar', () => { + test('the hgroup stays inside the 4em top bar', async ({ app }) => { + // The state a11y.29 landed in. The pair is flex-centred and a UA + // gives an `h1` a 0.67em top margin, so the block measured 67px + // inside 64 — pre-existing, and invisible until dropping the h3's + // bottom margin shortened the block and shifted it down into the + // clip. The descenders of "meant to bee." were cut. + const fits = await app.locator('hgroup').evaluate((el) => { + const bar = el.closest('.top-bar')!.getBoundingClientRect(); + const group = el.getBoundingClientRect(); + + return { + top: group.top >= Math.floor(bar.top), + bottom: group.bottom <= Math.ceil(bar.bottom), + height: Math.round(group.height), + }; + }); + + expect(fits.top).toBe(true); + expect(fits.bottom).toBe(true); + }); +}); diff --git a/e2e/specs/skip-link.spec.ts b/e2e/specs/skip-link.spec.ts new file mode 100644 index 0000000..3e02b5f --- /dev/null +++ b/e2e/specs/skip-link.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '../support/fixtures.js'; + +/** + * Plan 008 phase 3: `a11y.30` and `a11y.29`, the two findings that are + * about the document itself rather than about a component. + * + * `
` existed and nothing linked to it, so a + * keyboard user walked the library filter, the search box, the job + * indicator and eleven nav items before reaching content — on every + * navigation. And `

` was followed immediately by `

`, using a + * heading level for type size. + * + * Two things in the fix are only checkable here, because the Vitest + * tier has no `index.html` at all: + * + * - **The link is out of flow in both states.** `body` is a grid with + * named areas, so an in-flow extra child is auto-placed into one of + * them and silently takes a row from the shell. + * - **`
` carries `tabindex="-1"`.** A fragment link to an element + * that cannot hold focus moves the *scroll* and leaves the tab + * sequence exactly where it was, which is the whole thing the link + * exists to change — and it looks like it worked. + */ +test.describe('skipping to the content', () => { + test('is the first thing Tab reaches', async ({ app }) => { + // From the very top of the document, not from a control part-way + // in: "first" is the claim. + await app.evaluate(() => { + (document.activeElement as HTMLElement | null)?.blur(); + document.body.focus(); + }); + await app.keyboard.press('Tab'); + + const first = await app.evaluate(() => ({ + tag: document.activeElement?.tagName ?? '', + text: document.activeElement?.textContent?.trim() ?? '', + })); + + expect(first).toEqual({ tag: 'A', text: 'Skip to content' }); + }); + + test('is off screen until focused, and never takes a grid cell', async ({ + app, + }) => { + const offscreen = await app + .locator('.skip-link') + .evaluate((el) => el.getBoundingClientRect().left); + + expect(offscreen).toBeLessThan(-100); + + await app.locator('.skip-link').focus(); + + const box = await app + .locator('.skip-link') + .evaluate((el) => { + const r = el.getBoundingClientRect(); + + return { left: r.left, position: getComputedStyle(el).position }; + }); + + expect(box.left).toBeGreaterThanOrEqual(0); + expect(box.position).toBe('absolute'); + }); + + test('moves focus into the main panel, not just the scroll', async ({ + app, + }) => { + await app.locator('.skip-link').focus(); + await app.keyboard.press('Enter'); + + const landed = await app.evaluate( + () => document.activeElement?.id ?? '', + ); + + expect(landed).toBe('main-content'); + + // Leave the page as it was found: the specs share one page in file + // order, and focus inside `main` changes which shortcut scope the + // service resolves. + await app.evaluate(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }); + }); + + test('does not use a heading level for type size', async ({ app }) => { + // `hgroup` takes one heading plus paragraphs, so this is also what + // the element was supposed to contain. + await expect(app.locator('hgroup h3')).toHaveCount(0); + await expect(app.locator('hgroup p.subtitle')).toHaveText( + 'Music how it was meant to bee.', + ); + }); +}); diff --git a/frontend/index.css b/frontend/index.css index 83f5f60..0462734 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -24,6 +24,36 @@ p { /* I want to set paragraph margins myself */ } +/* a11y.30. Out of flow in both states, because `body` is a grid with + named areas and an in-flow extra child is auto-placed into one of + them — the link would silently take a row from the shell. It is not + `display: none`: a skip link that is not focusable is not a skip + link. */ +.skip-link { + position: absolute; + left: -9999px; + top: 0; + z-index: 100; + padding: 0.5em 1em; + background-color: var(--yj-accent); + color: var(--yj-accent-fg); + font-weight: 600; + text-decoration: none; + border-radius: 0 0 4px 0; +} + +.skip-link:focus { + left: 0; +} + +/* The target of that link, so it can take focus at all. `main` is not + focusable by default, and a fragment link to an unfocusable element + moves the scroll and leaves the tab sequence exactly where it was — + which is the whole thing the link exists to change. */ +.main-panel:focus { + outline: none; +} + .top-bar { grid-area: top-bar; height: 100%; @@ -46,7 +76,14 @@ ul { .title { font-size: 1.5em; - margin-bottom: 0; + /* Both margins, not just the bottom one. The pair is flex-centred + in a 4em bar and the UA gives an h1 a 0.67em top margin, so the + hgroup measured 67px inside 64 and the subtitle's descenders were + clipped by the bar. That was pre-existing; a11y.29 made it + visible by taking the h3's bottom margin away with it, which + shortened the block and moved the whole pair down into the clip. + This is the state that fix landed in. */ + margin-block: 0; } .subtitle { diff --git a/frontend/index.html b/frontend/index.html index c452149..86cb586 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,10 +10,15 @@ + +

YellowJacket

-

Music how it was meant to bee.

+ +

Music how it was meant to bee.

@@ -23,7 +28,8 @@
-
+
diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 424ab62..4a5e48b 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -943,7 +943,13 @@ export class TrackList } .sort-arrow { - font-size: 10px; /* intentionally sub-token: tiny sort indicator */ + /* a11y.34. Was 10px, below the type scale's own floor, with a + comment acknowledging it. The finding's stated harm — "the sort + direction is a 10px glyph or nothing" — is half closed already: + Phase 1 gave these cells an aria-sort, so it is announced. What + is left is a sighted user reading it, and nothing in the header + needs it to be smaller than the smallest text in the app. */ + font-size: var(--yj-text-xs, 11px); flex-shrink: 0; color: var(--yj-accent-text, #ffd43b); }