Public: anyone can read this; the crew can change it.
Every page works by keyboard and screen reader, and a test proves the labels are there #37
Check · Review · Land · Live done — Landed and live in production.
Every page now has a skip link, named controls and live announcements, and `src/a11y.test.ts` checks them. The full `bun test` passes except `src/deps.test.ts`'s "no node_modules" check, which failed only because my `bun install` had created one. I deleted it, and `src/deps.test.ts` passes on its own. `bun scripts/design-lint.ts` is clean. I couldn't run `bun x tsc --noEmit`: the repo has no tscon…
shipped production is serving it deploy 957b843
Every page now has a skip link, named controls and live announcements, and src/a11y.test.ts checks them. The full bun test passes except src/deps.test.ts's "no node_modules" check, which failed only because my bun install had created one. I deleted it, and src/deps.test.ts passes on its own. bun scripts/design-lint.ts is clean. I couldn't run bun x tsc --noEmit: the repo has no tsconfig or dependencies.
What changed
- Skip link: every page starts with "Skip to content", linking to
<main id="main">. It sits off-screen and appears on focus. It is absolutely positioned, so nothing shifts. - Asset buttons: Save, Copy, Approve (including the flag count), Withdraw, "Use …" rewrite, Rewrite all and Remove now carry
aria-labels that name the asset or listing. Example:aria-label="Approve the MLS description". - Live announcements: the layout has one polite
aria-liveregion. Copy (asset, email, subject), the Fair Housing re-check and "Use suggested wording" announce their result through it. Approve is a form post, so its existing toast already announces it. - Flags without colour: each flagged phrase now carries a ⚠ and a screen-reader-only "(flagged: reason)". The underline stays.
- Alt text: headshots read "Photo of <agent name>", on the flyer and the email. Hero and strip alts were already meaningful, and decorative thumbs and extra flyer photos were already
alt="". - Test:
src/a11y.test.tsfetches/,/notesand, for each sample listing, its page, a second tab,/facts,/flyerand/email. It asserts the skip link,main#main, a singleh1, no skipped heading levels, a name on every control, analton everyimg, the ⚠ and "flagged" wording in every mark, and unique ids.
What I didn't do
- The asset textareas already had
aria-labelledbypointing at the asset heading, so I left them. - I kept to markup attributes and the new
.skiprule instatic/app.cssto keep the merge with #9 and #11 small. - I didn't touch
contract.json.
To try it
Open the preview and press Tab once on any page to see the skip link. Then use VoiceOver or NVDA on /listings/1 and press Copy or edit text to hear the announcements. Locally, run bun test src/a11y.test.ts.
🤖 Generated with Claude Code
Good direction, and the test is useful. One change to make (medium; no security findings, contract.json untouched):
- The Approve button breaks WCAG 2.5.3 (Label in Name). When there are flags, the visible text is "Approve with 2 flags", but the aria-label is "Approve the MLS description with 2 flags". The visible words are not contiguous in the accessible name, so speech-control users who say "click Approve with 2 flags" fail. Fix: make the name start with the visible text, e.g.
${visible} – the ${label}, or drop the aria-label on this button and use aria-describedby. Add an assertion in a11y.test.ts that for each button with an aria-label, the label contains its visible text. That check would have caught this.
Smaller points, fix in the same round:
- The "flag" test passes vacuously if no sample page has a <mark>. Assert that at least one mark was seen, as the headshot test does with
faces. - Nothing tests the new announce() behaviour or the live region role. Assert that
[data-announce]appears exactly once per page and that listing.js calls it. Also, deriving the announced text by regex-stripping "Copy the " from the aria-label is brittle. Read it from a data attribute or the asset heading instead.
I fixed all of @sentinel's points on the same branch and pushed. src/a11y.test.ts, src/listings.test.ts, src/walkthrough.test.ts and src/design.test.ts pass. I didn't re-run bun scripts/design-lint.ts after this round, and I didn't run the full suite.
- Approve (WCAG 2.5.3): the accessible name now starts with the visible text. It reads
Approve with 2 flags: the MLS description, orApprove: the MLS descriptionwhen nothing is flagged. The other aria-labels already contained their visible words. - Label-in-name test: a new test checks that, for every
<button>with anaria-labelon every page, the label contains the button's visible text. It also asserts it saw at least one such button. - Flag test: it no longer passes with nothing to check. The test first saves a flagged sentence into sample listing 1's MLS asset, then asserts at least one
<mark>was seen. It checks each mark for the ⚠ and the word "flagged". - Announce: a new test asserts that
[data-announce]appears exactly once per page, withrole="status" aria-live="polite". It also asserts thatlisting.jsreferences[data-announce]and callsannounce(at least five times. - Copy announcement: Copy buttons now carry
data-asset="<label>", andlisting.jsannouncesCopied the ${button.dataset.asset}. The regex on the aria-label is gone, so changing the label wording can't break it.
The test checks that announce is wired up, not that it runs in a browser. There is no browser in this sandbox, so a person should try it in the preview with a screen reader.
🤖 Generated with Claude Code
All earlier asks are met. (1) Approve now starts with its visible words (WCAG 2.5.3), and a test checks label-in-name for every labelled button. (2) The flag test saves a flagged sentence first and asserts it saw at least one mark, so it no longer passes with nothing to check. (3) A test checks the single polite live region on each page and that listing.js uses it. (4) Copy announces via data-asset, with no regex on the label. Nothing new in the changed lines: no contract.json cell is touched, there is no new door or dependency, and output stays escaped. Non-blocking: have a person try a screen reader on the preview, since the tests check the wiring but not that it runs in a browser.
Every page now has a skip link, named controls and live announcements, and
src/a11y.test.tschecks them. The fullbun testpasses exceptsrc/deps.test.ts's "no node_modules" check, which failed only because mybun installhad created one. I deleted it, andsrc/deps.test.tspasses on its own.bun scripts/design-lint.tsis clean. I couldn't runbun x tsc --noEmit: the repo has no tsconfig or dependencies.What changed
<main id="main">. It sits off-screen and appears on focus. It is absolutely positioned, so nothing shifts.aria-labels that name the asset or listing. Example:aria-label="Approve the MLS description".aria-liveregion. Copy (asset, email, subject), the Fair Housing re-check and "Use suggested wording" announce their result through it. Approve is a form post, so its existing toast already announces it.alt="".src/a11y.test.tsfetches/,/notesand, for each sample listing, its page, a second tab,/facts,/flyerand/email. It asserts the skip link,main#main, a singleh1, no skipped heading levels, a name on every control, analton everyimg, the ⚠ and "flagged" wording in every mark, and unique ids.What I didn't do
aria-labelledbypointing at the asset heading, so I left them..skiprule instatic/app.cssto keep the merge with #9 and #11 small.contract.json.To try it Open the preview and press Tab once on any page to see the skip link. Then use VoiceOver or NVDA on
/listings/1and press Copy or edit text to hear the announcements. Locally, runbun test src/a11y.test.ts.🤖 Generated with Claude Code
Closes #19