Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions docs/screen-reader-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Testing this with a screen reader

A typing trainer is close to the worst case for a live region. Announce every
keystroke and it becomes an unusable firehose; announce nothing and a screen
reader user has no idea where they are in the text. The only way to know which
side of that line we are on is for somebody to listen to it.

This is a script for doing that. It takes about twenty minutes. You do not need
to know the codebase.

**If you do this, please say what you found on
[issue #10](https://github.com/RCheesley/touchwright/issues/10), including the
parts that were fine.** Knowing which announcements already work is as useful as
knowing which do not.

## What you need

One of:

- **VoiceOver** on macOS — `Cmd + F5` to start and stop. Safari is the best pairing.
- **NVDA** on Windows — free from nvaccess.org. Firefox or Chrome.
- **Orca** on Linux — usually `Super + Alt + S`.

And the app: <https://rcheesley.github.io/touchwright/>

You will need a layout export to load. If you do not have a Glove80, grab
[the reference layout](https://github.com/RCheesley/touchwright/blob/main/tests/fixtures/macos-maltron.glove80.json)
(the "Download raw file" button) — it is a real Maltron layout and the app is
built around it.

## A note before you start

The drill **captures the keyboard**. While a drill is running, printable keys go
to the drill rather than to the page.

**Escape always gives the keyboard back.** If you get stuck, press Escape.

This is exactly the interaction most worth your scrutiny, because it is the part
most likely to trap somebody.

## The script

Work through these in order. For each, note what you heard, what you expected,
and anything that was too much, too little, or too late.

### 1. Loading a layout

- [ ] Reach the file input by keyboard alone. Is it clear what file it wants?
- [ ] Load the layout. Is the confirmation announced?
- [ ] Load something that is not a layout — any other JSON or text file. Is the
error announced promptly, and does it tell you what to do about it?

### 2. The summary and the board

- [ ] The summary lists what the parser found. Does it read as a sensible list?
- [ ] The board diagram is deliberately hidden from screen readers
(`aria-hidden`), because everything it shows is meant to be in text.
**Check that claim.** Is anything about key positions available only in
the picture? The "Every key on the board, in words" disclosure is the
text equivalent — is it usable, or is 80 keys simply too many to hear?

### 3. Starting a drill — the important one

- [ ] Focus the drill surface. What is announced? You should hear its name and a
description naming the next key, its finger and its row.
- [ ] Is it clear **that the keyboard has been captured** and how to release it?
- [ ] Start typing correctly. **Is each keystroke announced?** It should not be.
- [ ] Is the current word announced as you move through the text? Is that the
right granularity — too often, not often enough?
- [ ] Do you always know what to type next without stopping to investigate?

### 4. Making mistakes

- [ ] Type a wrong key. Is the mistake announced? Does it name the key you
should have pressed, and the finger?
- [ ] Type the **same wrong key twice in a row**. Is the second one announced?
_(We think it may not be. See "What we already suspect" below.)_
- [ ] Press Backspace. Is anything said? Should something be?

### 5. Escape, and the trap question

- [ ] Press Escape mid-drill. Is the release announced?
- [ ] Can you now Tab through the page normally?
- [ ] Can you get back into the drill and resume?
- [ ] **At any point, were you stuck anywhere you could not leave by keyboard?**
This is the single most important question in this document.

### 6. Finishing a drill

- [ ] Complete a drill. Is the result announced? Once, or more than once?
- [ ] Are the numbers comprehensible when heard rather than seen — words per
minute, accuracy, stars?
- [ ] Is the next step clear? Do you know how to take it?

### 7. The ladder and the statistics

- [ ] Can you tell which lessons are locked and which are not, without seeing
the stars?
- [ ] The weak-key report groups keys by finger and row. Does it read
sensibly aloud? This is the app's whole point, so it matters most here.
- [ ] Are the severity bands (`steady`, `worth watching`, `needs work`)
audible as words rather than only as colour?

### 8. Export and import

- [ ] Export your progress. Is the download announced?
- [ ] Import it back. Are any warnings about dropped fields announced?

## What we already suspect is wrong

Found by reading the code, not by listening. Confirming or dismissing any of
these is useful.

1. **A repeated identical message may be silent.** Announcements are made by
setting the text of a live region. Setting it to the _same_ string changes
nothing in the page, so a screen reader has nothing to notice. Mistyping the
same key twice in a row may therefore announce once.
2. **No region is marked `aria-atomic`.** Multi-part messages may be announced
in fragments rather than whole, depending on the screen reader.
3. **Five polite regions can speak.** The capture state, the drill progress, the
current lesson, the load status and the progress status. If two change at
once, the order is whatever the screen reader decides. We do not know whether
this produces pile-ups in practice.
4. **The next-key description changes while the surface keeps focus.** It is
referenced by `aria-describedby`, and screen readers vary a lot in whether
they re-read a description that changes under them.

## What is already checked automatically

So you know what not to spend your time on. Every view is checked with axe in
both light and dark themes on every commit, and the end-to-end suite asserts:

- live regions exist with the politeness they are meant to have,
- the drill surface has an accessible name and a description naming the next key,
- the next key is named in words before it is highlighted in colour,
- individual keystrokes are **not** announced,
- Escape releases the keyboard, and Tab then moves normally,
- a whole drill can be completed without touching the mouse,
- nothing is conveyed by colour alone.

None of that tells us whether it is **usable**. That is what we are asking.
24 changes: 24 additions & 0 deletions src/ui/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,27 @@ export function describeFailure(cause: unknown): string {
if (cause instanceof Error) return cause.message;
return 'Something went wrong.';
}

/**
* Says something in a live region, in a way that a repeat is still heard.
*
* A live region speaks when its contents change. Setting it to the string it
* already holds changes nothing, so an assistive technology has nothing to
* notice and the message is silent. That matters here because the messages most
* likely to repeat are the ones a learner most needs: mistyping the same key
* twice in a row produces the same sentence twice, and the second one would
* never be spoken.
*
* A single trailing space is toggled so that consecutive identical messages are
* always a real change. A trailing space is chosen over a zero-width space
* because it cannot be mistaken for a character and read out; it is invisible in
* the rendering either way.
*
* Whether every screen reader treats a whitespace-only difference as a change is
* not something we can assert from here. docs/screen-reader-testing.md asks
* testers to confirm it, and it is the reason that document exists.
*/
export function announceInto(region: HTMLElement, message: string): void {
const previous = region.textContent;
region.textContent = previous === message ? `${message} ` : message;
}
31 changes: 20 additions & 11 deletions src/ui/progress-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
type WeakKey,
} from '../stats/keystats.js';
import type { ProgressStore } from '../stats/storage.js';
import { describeFailure, required } from './dom.js';
import { describeFailure, required, announceInto } from './dom.js';

/** Stars a lesson can be worth. The ladder shows all three, earned or not. */
export const MAX_STARS = 3;
Expand Down Expand Up @@ -562,7 +562,10 @@ export function createProgressView(options: ProgressViewOptions): ProgressView {
showError(`That export did not happen: ${describeFailure(cause)}`);
return;
}
el.status.textContent = `Exported ${fileName}. Keep it somewhere you will find it again; importing it restores your progress in any browser.`;
announceInto(
el.status,
`Exported ${fileName}. Keep it somewhere you will find it again; importing it restores your progress in any browser.`,
);
}

function onClear(): void {
Expand All @@ -571,8 +574,10 @@ export function createProgressView(options: ProgressViewOptions): ProgressView {
clearArmed = true;
el.clearButton.textContent = CLEAR_CONFIRM_LABEL;
el.clearButton.dataset['armed'] = 'true';
el.status.textContent =
'This will delete the progress saved in this browser, and it cannot be undone. Choose the button again to confirm, or press Escape to cancel. Export first if you want to keep it.';
announceInto(
el.status,
'This will delete the progress saved in this browser, and it cannot be undone. Choose the button again to confirm, or press Escape to cancel. Export first if you want to keep it.',
);
return;
}

Expand All @@ -594,14 +599,16 @@ export function createProgressView(options: ProgressViewOptions): ProgressView {
progress.keyStats = {};

refresh();
el.status.textContent =
'Saved progress cleared. The ladder is back at the first lesson and the statistics are empty.';
announceInto(
el.status,
'Saved progress cleared. The ladder is back at the first lesson and the statistics are empty.',
);
}

function onClearKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !clearArmed) return;
disarmClear();
el.status.textContent = 'Clearing cancelled. Your saved progress is untouched.';
announceInto(el.status, 'Clearing cancelled. Your saved progress is untouched.');
}

/**
Expand Down Expand Up @@ -658,7 +665,7 @@ export function createProgressView(options: ProgressViewOptions): ProgressView {
`${warnings.length} ${warnings.length === 1 ? 'part' : 'parts'} of it could not be read and ${warnings.length === 1 ? 'was' : 'were'} dropped; the rest was kept.`,
);
}
el.status.textContent = parts.join(' ');
announceInto(el.status, parts.join(' '));

showWarnings(
warnings.length === 1
Expand All @@ -676,7 +683,7 @@ export function createProgressView(options: ProgressViewOptions): ProgressView {
el.status.textContent = '';
return;
}
el.status.textContent = `Reading ${file.name}…`;
announceInto(el.status, `Reading ${file.name}…`);
void importFile(file);
}

Expand All @@ -694,10 +701,12 @@ export function createProgressView(options: ProgressViewOptions): ProgressView {
// rather than being overwritten by the first render.
const startupWarnings = options.startupWarnings ?? [];
if (startupWarnings.length > 0) {
el.status.textContent =
announceInto(
el.status,
startupWarnings.length === 1
? 'One part of the progress saved in this browser could not be read. Everything else was kept.'
: `${startupWarnings.length} parts of the progress saved in this browser could not be read. Everything else was kept.`;
: `${startupWarnings.length} parts of the progress saved in this browser could not be read. Everything else was kept.`,
);
showWarnings('From the progress saved in this browser:', startupWarnings);
}

Expand Down
59 changes: 59 additions & 0 deletions tests/functional/announce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* A live region speaks when its contents change, so the same message twice is
* heard once. The messages most likely to repeat here are the ones a learner
* most needs: mistyping the same key twice produces the same sentence twice.
*/

import { beforeEach, describe, expect, it } from 'vitest';
import { announceInto } from '../../src/ui/dom.js';

describe('announceInto', () => {
let region: HTMLElement;

beforeEach(() => {
document.body.innerHTML = '<p id="region" role="status" aria-live="polite"></p>';
const found = document.querySelector<HTMLElement>('#region');
if (found === null) throw new Error('fixture did not render');
region = found;
});

it('says the message', () => {
announceInto(region, 'Wrong key. “a” was expected.');
expect(region.textContent).toBe('Wrong key. “a” was expected.');
});

it('changes the text when the same message is said twice', () => {
announceInto(region, 'Wrong key.');
const first = region.textContent;
announceInto(region, 'Wrong key.');
expect(region.textContent).not.toBe(first);
});

it('keeps changing across a long run of identical messages', () => {
const seen: string[] = [];
for (let time = 0; time < 6; time += 1) {
announceInto(region, 'Wrong key.');
seen.push(region.textContent);
}
// Every message differs from the one before it, which is what makes it heard.
for (let time = 1; time < seen.length; time += 1) {
expect(seen[time]).not.toBe(seen[time - 1]);
}
});

it('differs only by trailing whitespace, so nothing extra can be read out', () => {
announceInto(region, 'Wrong key.');
announceInto(region, 'Wrong key.');
expect(region.textContent.trim()).toBe('Wrong key.');
// Not a zero-width space, which some screen readers announce as a character.
expect(region.textContent).not.toContain('​');
});

it('still changes when a repeat follows a different message', () => {
announceInto(region, 'Word 1 of 8: ask');
announceInto(region, 'Wrong key.');
const before = region.textContent;
announceInto(region, 'Wrong key.');
expect(region.textContent).not.toBe(before);
});
});
Loading