a11y(board): announce the board's own message, from one shared derivation - #208
Conversation
…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>
There was a problem hiding this comment.
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>
|
Fixed the ° → ♥ gap the a11y review found — good catch, it was real.
Test written first, as with the rest of this PR — it fails on the previous code with:
and it pins the other direction too: a flagship board really does draw °, so its name keeps it. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Closes #205.
The barrier
StaticBoardDisplaydraws its message as a wall ofaria-hiddenglyph 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. InThumbnailGrid, 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, soimage-altpasses in both themes.The fix
The default now derives the name from the message, matching
BoardDisplay:Board preview: PAGE ONE ALERTS.previewLabelstill wins when passed, soBoardShowcase'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.StaticBoardDisplaygainsmessageLabel?: (message: string) => string, exactlyBoardDisplay's signature and contract, so the renderers share one labelling API.One derivation for all three renderers
New
messageToTextinlib/board-characters. It reads the message withparseLine— 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.StaticBoardDisplay"Board preview"Board preview: <message>— the issueBoardTeaserBoardDisplay\{[^}]*\}regexA 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 whatBoardDisplayused to emit for that case.Guards (TDD — both written failing first)
scripts/ci/tests/board-accessible-name.test.mjsmounts the real components in jsdom and readsaria-labeloff 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 · explicitpreviewLabelwins ·messageLabelrebuilds wording and receives plain text · a text-free board gets the generic name · an empty board keepsemptyLabel. Runs inrelease:test(theautomationjob 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".playfunction onThumbnailGridholds 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