Skip to content

a11y(board): announce the board's own message, from one shared derivation - #208

Merged
jeffredodd merged 2 commits into
mainfrom
a11y/static-board-name-205
Aug 12, 2026
Merged

a11y(board): announce the board's own message, from one shared derivation#208
jeffredodd merged 2 commits into
mainfrom
a11y/static-board-name-205

Conversation

@jeffredodd

Copy link
Copy Markdown
Member

Closes #205.

The barrier

StaticBoardDisplay draws its message as a wall of aria-hidden glyph tiles and named the whole thing with the constant "Board preview". That label is the only thing a screen reader can read, so a board with real content announced as "Board preview, image" and nothing more. In ThumbnailGrid, four different boards announced identically while a sighted user read four different messages.

axe can't see this — "Board preview" is a non-empty name, so image-alt passes in both themes.

The fix

The default now derives the name from the message, matching BoardDisplay: Board preview: PAGE ONE ALERTS.

  • previewLabel still wins when passed, so BoardShowcase's curated per-plugin descriptions are untouched. It simply no longer has a default value, and so no longer silently replaces every board's content with the same string.
  • The issue's second option is taken too: StaticBoardDisplay gains messageLabel?: (message: string) => string, exactly BoardDisplay's signature and contract, so the renderers share one labelling API.

One derivation for all three renderers

New messageToText in lib/board-characters. It reads the message with parseLine — the same parser the tiles use — so the name says what is actually on the board: a color marker occupies a cell and contributes a space, end tags contribute nothing, literal braces survive, glyphs are uppercased because uppercase is the board's only case.

renderer before after
StaticBoardDisplay constant "Board preview" Board preview: <message> — the issue
BoardTeaser its own copy of the same four lines a call; identical output
BoardDisplay \{[^}]*\} regex same output for every message in the repo; differs only for lowercase input (now uppercased, as rendered), runs of internal whitespace (now collapsed), stray braces (now kept, as rendered)

A board of nothing but color tiles draws no text, so both full renderers fall back to their generic name instead of announcing a dangling "Board preview: " — which is what BoardDisplay used to emit for that case.

Guards (TDD — both written failing first)

  • scripts/ci/tests/board-accessible-name.test.mjs mounts the real components in jsdom and reads aria-label off the rendered board. A code-shape check is the wrong instrument when the question is what name the DOM actually exposes. Six tests: all three renderers carry their text · no color markup leaks · explicit previewLabel wins · messageLabel rebuilds wording and receives plain text · a text-free board gets the generic name · an empty board keeps emptyLabel. Runs in release:test (the automation job has no browser, and an accessible name is DOM state, not paint). Red before the change with: "StaticBoardDisplay announced "Board preview", which does not contain the text it draws".
  • A play function on ThumbnailGrid holds the property a user depends on in a real render: four boards, four distinct names, each carrying its own message. Negative control: pinning the label back to a constant fails it with "the thumbnails announce duplicate names (Board preview / Board preview / Board preview / Board preview)".

Visual impact

None — an accessible name is not painted, no story was added, and the new play only reads. No VRT baseline reseed.

🤖 Generated with Claude Code

…tion

Issue #205 — `StaticBoardDisplay` drew its message as a wall of `aria-hidden`
glyph tiles and named the whole thing with the constant `"Board preview"`. The
label is the only thing a screen reader can read, so a board rendered with real
content announced as "Board preview, image" and nothing else. In the
`ThumbnailGrid` story four different boards announced identically; a sighted
user read four different messages.

The default now derives the name from the message, matching `BoardDisplay`:
`Board preview: PAGE ONE ALERTS`. `previewLabel` still exists and still wins
when passed, so `BoardShowcase`'s curated per-plugin descriptions are
unaffected — it just no longer has a value, and so no longer silently replaces
every board's content with the same string. Alongside it, and to take the
issue's second option too, `StaticBoardDisplay` gains `messageLabel?: (message:
string) => string` with exactly `BoardDisplay`'s signature and contract, so the
renderers share one labelling API rather than one-off prop shapes.

The derivation itself is now shared: `messageToText` in lib/board-characters.
It reads a message with `parseLine` — the same parser the tiles use — so the
name says what is actually on the board: color markers occupy a cell and
contribute a space, end tags contribute nothing, literal braces survive, and
glyphs are uppercased because uppercase is the board's only case. All three
renderers call it:

- `StaticBoardDisplay` — new behaviour, the issue.
- `BoardTeaser` — was its own copy of the same four lines; now a call. Same
  output, one less place to drift.
- `BoardDisplay` — was a `\{[^}]*\}` regex. Same output for every message in
  the repo; it differs only for lowercase input (now uppercased, as rendered),
  runs of internal whitespace (now collapsed), and stray braces (now kept, as
  rendered). Its `messageLabel` contract — "color markup already stripped" —
  is unchanged.

A board of nothing but color tiles draws no text, so both full renderers fall
back to their generic name rather than announcing a dangling "Board preview: ".
That case previously produced exactly that dangling prefix in `BoardDisplay`.

Guards, both written failing first:

- `scripts/ci/tests/board-accessible-name.test.mjs` mounts the real components
  in jsdom and reads `aria-label` off the rendered board — a code-shape check
  is the wrong instrument when the question is what name the DOM exposes. Six
  tests: all three renderers carry their text, no color markup leaks, an
  explicit `previewLabel` wins, `messageLabel` rebuilds the wording and
  receives the plain text, a text-free board gets the generic name, an empty
  board keeps `emptyLabel`. Runs in `release:test` (the `automation` job has no
  browser, and an accessible name is DOM state, not paint).
- A `play` function on `ThumbnailGrid` holds the property a user depends on in
  a real render: four boards, four distinct names, each carrying its own
  message. Negative control — pinning the label back to a constant fails it
  with "the thumbnails announce duplicate names (Board preview / Board preview
  / Board preview / Board preview)".

Visually neutral: the accessible name is not painted, no story was added, and
the play only reads. No VRT baseline reseed.

Closes #205

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core improvement here is real and meaningful: thumbnail grids of boards used to announce identically to screen reader users, and now they don't. The derivation is also the right shape — one shared path through parseLine instead of three copies of the same regex, so the name is grounded in what the tiles actually draw.

One gap to fix before merge.


messageToText misses the Note device's ° → ♥ substitution — WCAG 1.1.1 (Non-text Content)

src/lib/board-characters.ts, new messageToText function (line 274)

messageToGrid applies a device-specific substitution for Note boards: ° renders visually as (see the comment at line 239, board-characters.ts). messageToText calls parseLine directly and carries the raw token value through unchanged, so it doesn't make that substitution.

For a StaticBoardDisplay with deviceType="note" and a message containing °, the visual tiles show ♥ while aria-label says "Board preview: …°…". A screen reader user hears "degree"; a sighted user sees ♥. The text alternative doesn't describe what's actually on the board — a WCAG 1.1.1 failure.

This gap is new for StaticBoardDisplay: before this PR its aria-label was always "Board preview" regardless of message content, so ° never reached the name. The same gap exists in BoardDisplay and BoardTeaser from before, but this PR widens it to the third renderer.

messageToText could accept an optional deviceType parameter and apply the same substitution messageToGrid does — the logic is a single conditional already present at board-characters.ts:240. Alternatively, a small private helper shared by both functions keeps the substitution in one place. Either way, StaticBoardDisplay's label memo at static-board-display.tsx:82 would need to pass deviceType through.


Everything else looks clean: the aria-hidden boundary on the tile container is correctly placed, role="img" with aria-label is the right ARIA pattern here, the messageLabel escape hatch keeps the name computation customisable without breaking the invariant, and the ThumbnailGrid.play check adds meaningful Storybook-layer coverage for the distinguishability property axe can't see.

Review on #208 — `messageToGrid` draws code 62 (`°`) as a heart on Note
hardware, and `messageToText` did not, so a Note board showed ♥ while
announcing "degree": a text alternative describing something the board is not
displaying (WCAG 1.1.1). The gap pre-existed in BoardDisplay and BoardTeaser;
this PR would have widened it to StaticBoardDisplay, whose name previously
carried no message content at all.

The substitution now lives in one `applyDeviceSubstitution` helper that both
functions call, so the tiles and the name cannot disagree, and `messageToText`
takes `deviceType` for the same reason `messageToGrid` does. Both full
renderers pass theirs through.

Test first: the new case in board-accessible-name.test.mjs fails on the
previous code with `StaticBoardDisplay announced "Board preview: LOVE °" for a
board drawing "LOVE ♥"`. It also pins the other direction — a flagship board
really does draw °, so its name keeps it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffredodd

Copy link
Copy Markdown
Member Author

Fixed the ° → ♥ gap the a11y review found — good catch, it was real.

messageToGrid substitutes code 62 for a heart on Note hardware and messageToText did not, so a Note board drew ♥ while announcing "degree". The substitution now lives in one applyDeviceSubstitution helper both functions call, and messageToText takes deviceType for the same reason messageToGrid does; both full renderers pass theirs through.

Test written first, as with the rest of this PR — it fails on the previous code with:

StaticBoardDisplay announced "Board preview: LOVE °" for a board drawing "LOVE ♥"

and it pins the other direction too: a flagship board really does draw °, so its name keeps it.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance looks clean — no action needed.

StaticBoardDisplay — new label memo (static-board-display.tsx:72): messageToText runs per [message, deviceType, previewLabel, messageLabel, emptyLabel] change, same trigger window as the existing grid memo. messageToText is strictly cheaper than messageToGrid (no 2D allocation, no fill to rows×cols) so the label memo is dominated by the grid memo that was already there.

messageLabel default (static-board-display.tsx:57): defaultMessageLabel is module-scope, so the destructuring default is a stable reference — the label memo dep and the memo() wrapper both stay stable when callers omit the prop. Good.

BoardDisplay — boardText memo (board-display.tsx:1324): deviceType is now a dep (it wasn't before). In practice deviceType is set once per mount, so this adds zero extra recomputations in normal use. The parseLine-based path is slightly heavier than the old regex, but the memo guards it, and this computation is trivial next to messageToGrid.

BoardTeaser (board-teaser.tsx:55): one-line teaser → split('\n') yields a one-element array → one parseLine call, same as before. Negligible.

Bundle: messageToText is ~30 lines added to an already-imported module. No new imports, no new dependencies. Tree-shake behaviour is unchanged.

Visual regression: the change touches only aria-label values, which are invisible to the pixel-diff test. No baseline updates in the diff — correct.

No perf concerns here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No accessibility findings.

The fix is correct end-to-end: messageToText reads the message through the same parser as the tile renderer, so the role="img" name says exactly what is on the board (including the Note ♥ substitution, satisfying WCAG 1.1.1), and all three renderers now share one derivation so they cannot drift apart again. The BoardTeaser intentionally omits deviceType from its label call and from its tile rendering both — the two stay consistent.

The new messageLabel escape hatch on StaticBoardDisplay is properly guarded (it only fires after confirming messageToText returned non-empty text), the explicit previewLabel override still wins, and the empty/null-message path still falls back to emptyLabel. The Storybook play function and the jsdom behavioural tests together pin down the uniqueness property that axe structurally cannot verify.

@jeffredodd
jeffredodd added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 069921a Aug 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a11y: static-board-display.tsx — accessible name drops the board's message content

1 participant