fix(a11y): add a skip link, demote the subtitle, and size the sort arrow

a11y.30: `<main id="main-content">` 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. Two things in it are load-bearing and only checkable
against the running document: the link is 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; and `<main>` needs tabindex="-1", or the
fragment link moves the scroll, leaves the tab sequence where it was,
and looks like it worked.

a11y.29: `<h1>` followed by `<h3>` for type size. An `hgroup` takes one
heading plus paragraphs, so a `<p>` is also what it was meant to hold.

a11y.34: the sort arrow was 10px, below the type scale's own floor,
with a comment acknowledging it. Half of that finding was closed by
Phase 1 — the direction is announced now, via aria-sort — and the other
half is one declaration.

And the state that landed in: the hgroup measured 67px inside a 64px
bar, so dropping the h3's bottom margin shortened the block, moved the
flex-centred pair down, and clipped the subtitle's descenders. The
overflow was pre-existing; `margin-block: 0` on the title is the fix,
pinned by a new layout-overflow case.
This commit is contained in:
2026-08-13 02:09:44 -04:00
parent 2b41c27616
commit 9d420cda0a
5 changed files with 169 additions and 4 deletions
+23
View File
@@ -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);
});
});
+93
View File
@@ -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.
*
* `<main id="main-content">` 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 `<h1>` was followed immediately by `<h3>`, 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.
* - **`<main>` 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.',
);
});
});
+38 -1
View File
@@ -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 {
+8 -2
View File
@@ -10,10 +10,15 @@
</head>
<body>
<!-- a11y.30. First focusable thing in the document, so a keyboard
user is not walked through the header, the library filter, the
search box and eleven nav items on every navigation. -->
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="top-bar">
<hgroup>
<h1 class="title">YellowJacket</h1>
<h3 class="subtitle">Music how it was meant to bee.</h3>
<!-- a11y.29: a heading level was being used for type size. -->
<p class="subtitle">Music how it was meant to bee.</p>
</hgroup>
<library-filter></library-filter>
<search-bar></search-bar>
@@ -23,7 +28,8 @@
<app-sidebar></app-sidebar>
</div>
<div class="content-area">
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content">
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content"
tabindex="-1">
<track-list></track-list>
</main>
<queue-panel id="queue-panel"></queue-panel>
@@ -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);
}