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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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-live region. 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.ts fetches /, /notes and, for each sample listing, its page, a second tab, /facts, /flyer and /email. It asserts the skip link, main#main, a single h1, no skipped heading levels, a name on every control, an alt on every img, the ⚠ and "flagged" wording in every mark, and unique ids.

What I didn't do

  • The asset textareas already had aria-labelledby pointing at the asset heading, so I left them.
  • I kept to markup attributes and the new .skip rule in static/app.css to 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

Closes #19

commented

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-live region. 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.ts fetches /, /notes and, for each sample listing, its page, a second tab, /facts, /flyer and /email. It asserts the skip link, main#main, a single h1, no skipped heading levels, a name on every control, an alt on every img, the ⚠ and "flagged" wording in every mark, and unique ids.

What I didn't do

  • The asset textareas already had aria-labelledby pointing at the asset heading, so I left them.
  • I kept to markup attributes and the new .skip rule in static/app.css to 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

Changes requested — before the latest push

Good direction, and the test is useful. One change to make (medium; no security findings, contract.json untouched):

  1. 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.
commented

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, or Approve: the MLS description when 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 an aria-label on 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, with role="status" aria-live="polite". It also asserts that listing.js references [data-announce] and calls announce( at least five times.
  • Copy announcement: Copy buttons now carry data-asset="<label>", and listing.js announces Copied 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs