Compare commits

...
2 Commits
Author SHA1 Message Date
logan 254646da5e fix(a11y): mark the playing row with a shape, not only a colour
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 2m18s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 3m51s
a11y.22, WCAG 1.4.1: `.track-row.active` was a background tint and a
text colour, and the row markup carried no aria-current either — so a
colour-blind user could not find the playing row and AT had no signal
at all. The queue panel had aria-current from Phase 1 and the same
colour-only visual.

A triangle drawn in each row's own left padding by `::before`. It is a
shape that is present or absent, and it costs no layout: track-list's
grid columns are computed from the host width, so a marker in the flow
would move every cell on the playing row and nothing else.

Both directions are asserted in both tiers. A marker that renders on
every row satisfies "the playing row has one" for free, which is this
plan's oldest rule.

And one thing the reproduction found: a track started from the *list*
leaves the queue's currentIndex at -1, so the panel has no current row
in that flow at all. Pre-existing, and the reason this looked broken
the first time it was checked in the running app.
2026-08-13 02:14:55 -04:00
logan 9d420cda0a 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.
2026-08-13 02:09:44 -04:00
9 changed files with 339 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);
});
});
+35
View File
@@ -141,3 +141,38 @@ test.describe('queue', () => {
await expect(shuffle).toHaveAttribute('aria-pressed', 'false');
});
});
test.describe('the playing row is findable without colour vision', () => {
test('the queue marks its current row with a shape and aria-current', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await app.getByTestId('track-row').first().dblclick();
const queueToggle = app.locator('#queue-button');
await queueToggle.click();
const row = app.getByTestId('queue-row').first();
await expect(row).toBeVisible();
// Played *from the queue*, because a track started from the list
// leaves `currentIndex` at -1 — so the panel has no current row at
// all in that flow, which is what made this marker look broken the
// first time it was checked.
await row.dblclick();
await expect(row).toHaveAttribute('aria-current', 'true');
const marker = await row.evaluate(
(el) => getComputedStyle(el, '::before').borderLeftWidth,
);
// `a11y.22`: a background tint and a text colour were the only two
// signals, and both are hue (WCAG 1.4.1).
expect(parseFloat(marker)).toBeGreaterThan(0);
await queueToggle.click();
await expect(app.getByTestId('queue-row')).toHaveCount(0);
});
});
+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>
@@ -378,6 +378,22 @@ export class QueuePanel
color: var(--yj-accent-text, #ffd43b);
}
/* a11y.22, the same rule as track-list one panel over: the
playing row was a tint and a text colour and nothing else.
The triangle lives in the row's own 16px left padding, so it
is a shape that is present or absent and the flex row does
not move. */
.track-item.active::before {
content: '';
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
border-left: 5px solid var(--yj-accent-text, #ffd43b);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.track-art {
width: 32px;
height: 32px;
@@ -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);
}
@@ -996,6 +1002,7 @@ export class TrackList
}
.track-row {
position: relative;
display: grid;
grid-template-columns: var(--grid-cols);
font-size: var(--yj-text-sm);
@@ -1036,6 +1043,24 @@ export class TrackList
color: var(--yj-accent-text, #ffd43b);
}
/* a11y.22: the playing row was a background tint and a text colour
and nothing else, so a colour-blind user could not find it
(WCAG 1.4.1). A triangle drawn in the row's own 8px left padding
is a *shape* that is present or absent, and it costs no layout:
the grid columns are computed from the host width and every one
of them would have had to move for a marker in the flow. Sized
to that padding rather than to the glyph a font would give. */
.track-row.active::before {
content: '';
position: absolute;
left: 1px;
top: 50%;
transform: translateY(-50%);
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.track-row.selected.active {
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
@@ -1802,6 +1827,7 @@ export class TrackList
role="row"
aria-rowindex=${index + 1}
aria-selected=${selected}
aria-current=${active ? 'true' : 'false'}
tabindex=${index === this.focusedIndex ? 0 : -1}
draggable="true"
data-index=${index}
@@ -235,3 +235,87 @@ describe('a clipped value is readable somewhere', () => {
expect(shadow(el, '.secondary')?.getAttribute('title')).toBeTruthy();
});
});
describe('the playing row is more than a colour', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
emit(Events.LibraryScanComplete);
});
/** The row the player says it is on, once the list has settled. */
async function listWithPlayingTrack(): Promise<LitElement> {
const el = await fixture<LitElement>('track-list');
sized(el);
await settle(el);
emit(Events.TrackChanged, {
fileName: 'a.mp3',
filePath: '/m/a.mp3',
trackLength: 4,
seekPosition: 0,
state: 'playing',
title: 'Departure',
artist: 'Aurora Fields',
album: 'Glass Harbour',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
});
await settle(el);
return el;
}
it('marks exactly one row aria-current', async () => {
const el = await listWithPlayingTrack();
const current = shadowAll(el, '.track-row[aria-current="true"]');
// The positive case first: a guard that marks nothing passes
// "at most one" for free.
expect(current).toHaveLength(1);
expect(current[0]!.classList.contains('active')).toBe(true);
});
it('draws a marker that is a shape, not a hue', async () => {
const el = await listWithPlayingTrack();
const active = shadow(el, '.track-row.active');
const other = shadowAll(el, '.track-row:not(.active)')[0];
expect(active, 'no active row rendered').toBeTruthy();
// `::before` has no box unless it has content, so a width of zero
// on the inactive row is the assertion that the marker is *absent*
// there — which is the half that makes the present one mean
// something (a11y.22, WCAG 1.4.1).
const width = (el: Element) =>
parseFloat(getComputedStyle(el, '::before').borderLeftWidth) || 0;
expect(width(active!)).toBeGreaterThan(0);
expect(width(other!)).toBe(0);
});
it('does not move the row it marks', async () => {
const el = await listWithPlayingTrack();
const rows = shadowAll(el, '.track-row');
const lefts = rows.map(
(r) => r.querySelector('.cell')!.getBoundingClientRect().left,
);
// The marker lives in the row's own padding: the grid columns are
// computed from the host width, so anything in the flow would move
// every cell on the playing row and nothing else.
expect(new Set(lefts.map(Math.round)).size).toBe(1);
});
});
@@ -194,6 +194,21 @@ describe('a queue row says which track its controls act on', () => {
]);
});
it('marks the playing row with a shape, not only a colour', async () => {
const el = await panelWithQueue();
const rows = shadowAll(el, '.track-item');
const width = (r: Element) =>
parseFloat(getComputedStyle(r, '::before').borderLeftWidth) || 0;
// `a11y.22`, the same rule as track-list one panel over. Both
// directions: absent on the rows that are not playing is what
// makes the present one mean anything.
expect(rows[0]!.getAttribute('aria-current')).toBe('true');
expect(width(rows[0]!)).toBeGreaterThan(0);
expect(width(rows[1]!)).toBe(0);
});
it('gives the title and artist a tooltip, since the panel is resizable', async () => {
const el = await panelWithQueue();