From bd12e8cb37005c082b436f60d15f8ad9e8fc4ab2 Mon Sep 17 00:00:00 2001 From: Jeffrey Johnson Date: Tue, 11 Aug 2026 20:11:45 -0700 Subject: [PATCH 1/2] a11y(board): announce the board's own message, from one shared derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ci/tests/board-accessible-name.test.mjs | 230 ++++++++++++++++++ src/components/board/board-display.tsx | 18 +- src/components/board/board-teaser.tsx | 13 +- .../board/static-board-display.stories.tsx | 37 ++- src/components/board/static-board-display.tsx | 36 ++- src/lib/board-characters.ts | 32 +++ 6 files changed, 346 insertions(+), 20 deletions(-) create mode 100644 scripts/ci/tests/board-accessible-name.test.mjs diff --git a/scripts/ci/tests/board-accessible-name.test.mjs b/scripts/ci/tests/board-accessible-name.test.mjs new file mode 100644 index 00000000..0a58ce0a --- /dev/null +++ b/scripts/ci/tests/board-accessible-name.test.mjs @@ -0,0 +1,230 @@ +/** + * Behavioral regression test for issue #205: a board with content must not + * announce a generic name. + * + * `StaticBoardDisplay` draws its message as a wall of glyph tiles inside an + * `aria-hidden` container, so the `role="img"` label is the *only* thing a + * screen reader can read. Its default was the constant `"Board preview"`, so + * four different boards in a thumbnail grid all announced identically while a + * sighted user read four different messages. axe cannot catch this: a non-empty + * `aria-label` passes `image-alt` no matter what it says. + * + * A code-shape check would be the wrong instrument — what matters is the name + * the DOM actually exposes, for a real message, through whichever prop the + * consumer set. So this mounts the real components (React + react-dom in jsdom) + * and reads `aria-label` off the rendered board. jsdom rather than a browser + * because `release:test` runs in CI's `automation` job, which does `npm ci` but + * never `npx playwright install`; an accessible *name* is DOM state, not paint, + * so nothing here needs a compositor. Same harness shape as + * board-flap-cascade.test.mjs. + * + * What it pins down: + * 1. all three renderers put the message's text in their default name; + * 2. color markup never leaks into the name; + * 3. an explicit `previewLabel` still wins (BoardShowcase depends on it); + * 4. `messageLabel` can rebuild the wording and receives the plain text; + * 5. a board that draws no text falls back to a generic name rather than + * announcing a dangling "Board preview:"; + * 6. an empty board still announces `emptyLabel`. + */ + +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { build } from "esbuild"; +import { JSDOM } from "jsdom"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const boardDir = path.resolve(here, "../../../src/components/board"); + +const HARNESS = ` + import { createElement } from "react"; + import { createRoot } from "react-dom/client"; + import { BoardDisplay } from "./board-display"; + import { StaticBoardDisplay } from "./static-board-display"; + import { BoardTeaser } from "./board-teaser"; + + const COMPONENTS = { BoardDisplay, StaticBoardDisplay, BoardTeaser }; + + export function mount(container, name, props) { + const root = createRoot(container); + root.render(createElement(COMPONENTS[name], props)); + return () => root.unmount(); + } +`; + +const MESSAGE = "HELLO WORLD\n{red}WELCOME TO{/red}\nFIESTABOARD"; +/** What the tiles actually draw, in reading order. */ +const MESSAGE_TEXT = "HELLO WORLD WELCOME TO FIESTABOARD"; + +let tmp; +let bundleUrl; +let runCounter = 0; + +before(async () => { + tmp = await mkdtemp(path.join(tmpdir(), "board-accessible-name-")); + const outfile = path.join(tmp, "harness.mjs"); + await build({ + stdin: { contents: HARNESS, resolveDir: boardDir, sourcefile: "harness.js", loader: "js" }, + outfile, + bundle: true, + format: "esm", + platform: "browser", + jsx: "automatic", + define: { "process.env.NODE_ENV": '"development"' }, + logLevel: "silent", + }); + bundleUrl = pathToFileURL(outfile).href; +}); + +after(async () => { + if (tmp) await rm(tmp, { recursive: true, force: true }); +}); + +/** Install a jsdom window as the global environment (see board-flap-cascade). */ +function installDom() { + const dom = new JSDOM("
", { + pretendToBeVisual: true, + }); + const { window } = dom; + window.matchMedia = (query) => ({ + media: query, + matches: false, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }); + + const globals = [ + "window", + "document", + "navigator", + "HTMLElement", + "Element", + "Node", + "Event", + "MessageChannel", + "MessagePort", + "requestAnimationFrame", + "cancelAnimationFrame", + "getComputedStyle", + ]; + const saved = new Map(); + const put = (key, value) => { + Object.defineProperty(globalThis, key, { value, configurable: true, writable: true }); + }; + for (const key of globals) { + saved.set(key, Reflect.get(globalThis, key)); + put(key, key === "window" ? window : window[key]); + } + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + + return { + window, + restore() { + for (const [key, value] of saved) put(key, value); + dom.window.close(); + }, + }; +} + +/** Mount one board and return the accessible name its `role="img"` exposes. */ +async function accessibleName(component, props) { + const dom = installDom(); + try { + // A fresh module instance per run: reduced-motion.ts resolves its + // MediaQueryList at module scope and would otherwise carry the previous + // run's window into this one. + const harness = await import(`${bundleUrl}?run=${++runCounter}`); + const container = dom.window.document.getElementById("root"); + const unmount = harness.mount(container, component, props); + + let img = null; + for (let i = 0; i < 100 && !img; i++) { + img = container.querySelector('[role="img"]'); + if (!img) await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (!img) throw new Error(`${component} never rendered a role="img"`); + + const label = img.getAttribute("aria-label"); + unmount(); + // React schedules through MessageChannel, so unmount's work is still + // queued here. Let it drain before `restore()` closes the window, or a + // stray callback lands on a dead document after the test has ended. + await new Promise((resolve) => setTimeout(resolve, 10)); + return label; + } finally { + dom.restore(); + } +} + +// The heart of #205: the renderers disagreed about whether a board's *content* +// belongs in its name. StaticBoardDisplay said no, and it is the one used for +// lists and thumbnails, where telling boards apart is the whole job. +test("every renderer puts the board's own text in its default accessible name", async () => { + for (const [component, props] of [ + ["BoardDisplay", { message: MESSAGE }], + ["StaticBoardDisplay", { message: MESSAGE }], + ["BoardTeaser", { teaser: "HELLO WORLD", tiles: 15 }], + ]) { + const name = await accessibleName(component, props); + const expected = component === "BoardTeaser" ? "HELLO WORLD" : MESSAGE_TEXT; + assert.ok( + name.includes(expected), + `${component} announced ${JSON.stringify(name)}, which does not contain the text it draws ` + + `(${JSON.stringify(expected)}). A screen-reader user cannot tell two boards apart by a name that ` + + "omits their content (issue #205).", + ); + } +}); + +test("color markup never reaches the accessible name", async () => { + for (const [component, props] of [ + ["BoardDisplay", { message: MESSAGE }], + ["StaticBoardDisplay", { message: MESSAGE }], + ]) { + const name = await accessibleName(component, props); + assert.doesNotMatch(name, /[{}]|\bred\b/i, `${component} leaked color markup into its name: ${name}`); + } +}); + +test("an explicit previewLabel still wins over the derived name", async () => { + // BoardShowcase passes a hand-written label for a curated preview; #205 must + // not take that away from it. + const name = await accessibleName("StaticBoardDisplay", { + message: MESSAGE, + previewLabel: "Air Quality & Fog displayed on a split-flap board", + }); + assert.equal(name, "Air Quality & Fog displayed on a split-flap board"); +}); + +test("messageLabel rebuilds the wording and receives the board's plain text", async () => { + const name = await accessibleName("StaticBoardDisplay", { + message: MESSAGE, + messageLabel: (msg) => `Thumbnail — ${msg}`, + }); + assert.equal(name, `Thumbnail — ${MESSAGE_TEXT}`); +}); + +test("a board that draws no text falls back to a generic name", async () => { + // Colour-only boards render tiles but no glyphs; "Board preview: " with + // nothing after it would be worse than the generic name it replaced. + const name = await accessibleName("StaticBoardDisplay", { message: "{63}{64}{65}" }); + assert.equal(name, "Board preview"); +}); + +test("an empty board still announces its emptyLabel", async () => { + assert.equal(await accessibleName("StaticBoardDisplay", { message: null }), "Empty board display"); + assert.equal(await accessibleName("StaticBoardDisplay", { message: "" }), "Empty board display"); + assert.equal( + await accessibleName("StaticBoardDisplay", { message: null, emptyLabel: "No message set" }), + "No message set", + ); +}); diff --git a/src/components/board/board-display.tsx b/src/components/board/board-display.tsx index 8c70ed2d..34b9ca39 100644 --- a/src/components/board/board-display.tsx +++ b/src/components/board/board-display.tsx @@ -32,6 +32,7 @@ import { getCharIndex, isColorTile, messageToGrid, + messageToText, tokensEqual, } from "../../lib/board-characters"; import { resolveColorCode } from "../../lib/board-colors"; @@ -1236,6 +1237,9 @@ export interface BoardDisplayProps { // Module-scope default so the aria-label memo below keeps a stable dependency. const defaultMessageLabel = (msg: string) => `Board display: ${msg}`; +/** Name for a board that renders no text at all — an all-color board. */ +const NO_TEXT_LABEL = "Board display"; + export const BoardDisplay = memo( function BoardDisplay({ message, @@ -1320,12 +1324,14 @@ export const BoardDisplay = memo( const boardText = useMemo(() => { if (isLoading) return loadingLabel; if (!message) return emptyLabel; - return messageLabel( - message - .replace(/\{[^}]*\}/g, "") - .replace(/\n/g, " ") - .trim(), - ); + // `messageToText` rather than a local regex (issue #205): it reads the + // message with the same parser the tiles do, so the name says what is + // actually on the board, and all three renderers now derive it one way. + const text = messageToText(message); + // A board of nothing but color tiles draws no text; it is not empty, so + // it gets the generic name rather than `emptyLabel` or a dangling + // "Board display: " with nothing after it. + return text ? messageLabel(text) : NO_TEXT_LABEL; }, [message, isLoading, loadingLabel, emptyLabel, messageLabel]); return ( diff --git a/src/components/board/board-teaser.tsx b/src/components/board/board-teaser.tsx index d6809259..22b788f7 100644 --- a/src/components/board/board-teaser.tsx +++ b/src/components/board/board-teaser.tsx @@ -14,7 +14,7 @@ import { memo, useMemo } from "react"; -import { type BoardToken, parseLine } from "../../lib/board-characters"; +import { type BoardToken, messageToText, parseLine } from "../../lib/board-characters"; import { resolveColorCode } from "../../lib/board-colors"; import { gapClasses, radiusClasses, sizeClasses, textSizeClasses } from "../../lib/board-metrics"; import { charLeafBoxShadow, SEAM_CLASS, seamStyle } from "./board-surfaces"; @@ -50,14 +50,9 @@ export const BoardTeaser = memo(function BoardTeaser({ // Plain-text teaser for the accessible label: color markers stripped, // whitespace collapsed. Falls back to a generic label for color-only strips. - const label = useMemo(() => { - const text = parseLine(teaser) - .map((token) => (token.type === "char" ? token.value : " ")) - .join("") - .replace(/\s+/g, " ") - .trim(); - return text || "Board teaser"; - }, [teaser]); + // `messageToText` is the derivation all three renderers share (issue #205) — + // this used to be its own copy of the same few lines. + const label = useMemo(() => messageToText(teaser) || "Board teaser", [teaser]); // Tile metrics (sizeClasses/textSizeClasses/gapClasses) come from // ../../lib/board-metrics so a teaser strip matches a full board row rendered diff --git a/src/components/board/static-board-display.stories.tsx b/src/components/board/static-board-display.stories.tsx index 73a36bc3..01792b48 100644 --- a/src/components/board/static-board-display.stories.tsx +++ b/src/components/board/static-board-display.stories.tsx @@ -46,7 +46,13 @@ const meta = { }, previewLabel: { control: "text", - description: "Accessible label when a message is shown", + description: + "Fixed accessible label for a shown message, overriding the derived one. Leave unset unless a hand-written description beats the board's own text — every board given the same string announces identically (issue #205).", + }, + messageLabel: { + control: false, + description: + "Builds the accessible label for a shown message, color markup already stripped. Defaults to `Board preview: ${message}`, the same contract as BoardDisplay's prop of this name.", }, emptyLabel: { control: "text", @@ -105,6 +111,12 @@ export const Empty: Story = { /** * Thumbnail grid — the use case this variant exists for: many boards at once. * + * It is also the case that made issue #205 concrete: with a constant default + * label these four boards all announced "Board preview, image" and a + * screen-reader user had no way to tell them apart. Each now announces its own + * message — "Board preview: PAGE ONE ALERTS", and so on. Inspect the four + * `role="img"` names in the a11y panel to see it. + * * Two 22-column thumbnails need ~774px, so on a phone the grid scrolls rather * than squeezing the boards: a `sm` board's 379px floor is not negotiable * (issue #192), and until `shrink-0` was added to the tiles a squeezed cell @@ -126,3 +138,26 @@ export const ThumbnailGrid = () => ( ); + +// Browser-side half of the #205 guard: four boards, four different names. The +// behavioural detail (what each name says, which prop wins) is asserted in +// jsdom by scripts/ci/tests/board-accessible-name.test.mjs; this holds the +// property a user actually depends on in a real render — that the boards in a +// grid are distinguishable by name at all. +ThumbnailGrid.play = async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const names = Array.from(canvasElement.querySelectorAll('[data-slot="static-board-display"]')).map( + (board) => board.getAttribute("aria-label") ?? "", + ); + + if (names.length !== 4) throw new Error(`expected four thumbnails, found ${names.length}`); + if (new Set(names).size !== names.length) { + throw new Error( + `the thumbnails announce duplicate names (${names.join(" / ")}) — a screen-reader user cannot tell them ` + + "apart (issue #205)", + ); + } + const missing = names.filter((name) => !/PAGE (ONE|TWO|THREE|FOUR)/.test(name)); + if (missing.length > 0) { + throw new Error(`these names do not carry their board's message: ${missing.join(" / ")} (issue #205)`); + } +}; diff --git a/src/components/board/static-board-display.tsx b/src/components/board/static-board-display.tsx index 8c404849..949297c3 100644 --- a/src/components/board/static-board-display.tsx +++ b/src/components/board/static-board-display.tsx @@ -12,7 +12,7 @@ import { memo, useMemo } from "react"; -import { messageToGrid } from "../../lib/board-characters"; +import { messageToGrid, messageToText } from "../../lib/board-characters"; import { resolveColorCode } from "../../lib/board-colors"; import { type DeviceType, isNoteArray, NOTE_COLS, NOTE_ROWS, resolveDimensions } from "../../lib/board-dimensions"; import { gapClasses, paddingClasses, radiusClasses, sizeClasses, textSizeClasses } from "../../lib/board-metrics"; @@ -28,12 +28,27 @@ export interface StaticBoardDisplayProps { notesWide?: number; /** Notes tall (for note_array device; ignored otherwise). */ notesTall?: number; - /** Accessible label when a message is shown. */ + /** Fixed accessible label for a shown message. Overrides `messageLabel`, so + * pass it only when a hand-written description beats the board's own text + * (BoardShowcase's curated previews do). Note that it makes every board it + * is passed to announce identically — the default derives the name from the + * message instead. */ previewLabel?: string; + /** Builds the accessible label for a shown message (color markup already + * stripped). Same contract as BoardDisplay's prop of this name; defaults to + * `Board preview: ${message}`. */ + messageLabel?: (message: string) => string; /** Accessible label when the board has no message. */ emptyLabel?: string; } +// Module-scope so the memoized component sees a stable prop identity, and the +// name memo below a stable dependency. +const defaultMessageLabel = (msg: string) => `Board preview: ${msg}`; + +/** Name for a board that renders no text at all — an all-color board. */ +const NO_TEXT_LABEL = "Board preview"; + export const StaticBoardDisplay = memo(function StaticBoardDisplay({ message, size = "sm", @@ -42,7 +57,8 @@ export const StaticBoardDisplay = memo(function StaticBoardDisplay({ className = "", notesWide = 1, notesTall = 1, - previewLabel = "Board preview", + previewLabel, + messageLabel = defaultMessageLabel, emptyLabel = "Empty board display", }: StaticBoardDisplayProps) { const dims = resolveDimensions(deviceType, notesWide, notesTall); @@ -56,6 +72,18 @@ export const StaticBoardDisplay = memo(function StaticBoardDisplay({ [message, dims.rows, dims.cols, deviceType], ); + // The board's whole accessible name: the tiles below are aria-hidden, so + // whatever this says is all a screen reader ever gets (issue #205). It + // carries the message by default — a thumbnail grid of four boards that all + // announce "Board preview" is four boards a screen-reader user cannot tell + // apart, while a sighted user reads four different messages. + const label = useMemo(() => { + if (!message) return emptyLabel; + if (previewLabel !== undefined) return previewLabel; + const text = messageToText(message); + return text ? messageLabel(text) : NO_TEXT_LABEL; + }, [message, previewLabel, messageLabel, emptyLabel]); + // Seam gap: additional left/top margin applied at Note physical boundaries const seamGap = size === "sm" ? "6px" : size === "md" ? "8px" : "10px"; @@ -87,7 +115,7 @@ export const StaticBoardDisplay = memo(function StaticBoardDisplay({
+ parseLine(line) + .map((token) => (token.type === "char" ? token.value : " ")) + .join(""), + ) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} From 9f7ea941500f13b93771dd7abd171b98fdc6dcdb Mon Sep 17 00:00:00 2001 From: Jeffrey Johnson Date: Tue, 11 Aug 2026 20:19:53 -0700 Subject: [PATCH 2/2] a11y(board): keep the Note heart substitution in the accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ci/tests/board-accessible-name.test.mjs | 13 ++++++++ src/components/board/board-display.tsx | 4 +-- src/components/board/static-board-display.tsx | 6 ++-- src/lib/board-characters.ts | 32 +++++++++++++------ 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/scripts/ci/tests/board-accessible-name.test.mjs b/scripts/ci/tests/board-accessible-name.test.mjs index 0a58ce0a..3e8046a3 100644 --- a/scripts/ci/tests/board-accessible-name.test.mjs +++ b/scripts/ci/tests/board-accessible-name.test.mjs @@ -213,6 +213,19 @@ test("messageLabel rebuilds the wording and receives the board's plain text", as assert.equal(name, `Thumbnail — ${MESSAGE_TEXT}`); }); +test("the name says what the tiles draw on a Note, where ° is a heart", async () => { + // messageToGrid substitutes code 62 for a heart on Note hardware. A name + // derived without that substitution announces "degree" for a tile showing ♥, + // which is a text alternative describing something else (WCAG 1.1.1). + for (const component of ["BoardDisplay", "StaticBoardDisplay"]) { + const name = await accessibleName(component, { message: "LOVE °", deviceType: "note" }); + assert.match(name, /LOVE ♥/, `${component} announced ${JSON.stringify(name)} for a board drawing "LOVE ♥"`); + } + // …and only on Note: everywhere else the tile really is a degree sign. + const flagship = await accessibleName("StaticBoardDisplay", { message: "52 °F" }); + assert.match(flagship, /52 °F/, `a flagship board draws °, so its name must keep it; got ${flagship}`); +}); + test("a board that draws no text falls back to a generic name", async () => { // Colour-only boards render tiles but no glyphs; "Board preview: " with // nothing after it would be worse than the generic name it replaced. diff --git a/src/components/board/board-display.tsx b/src/components/board/board-display.tsx index 34b9ca39..7e48f5d2 100644 --- a/src/components/board/board-display.tsx +++ b/src/components/board/board-display.tsx @@ -1327,12 +1327,12 @@ export const BoardDisplay = memo( // `messageToText` rather than a local regex (issue #205): it reads the // message with the same parser the tiles do, so the name says what is // actually on the board, and all three renderers now derive it one way. - const text = messageToText(message); + const text = messageToText(message, deviceType); // A board of nothing but color tiles draws no text; it is not empty, so // it gets the generic name rather than `emptyLabel` or a dangling // "Board display: " with nothing after it. return text ? messageLabel(text) : NO_TEXT_LABEL; - }, [message, isLoading, loadingLabel, emptyLabel, messageLabel]); + }, [message, deviceType, isLoading, loadingLabel, emptyLabel, messageLabel]); return (
diff --git a/src/components/board/static-board-display.tsx b/src/components/board/static-board-display.tsx index 949297c3..c1edac84 100644 --- a/src/components/board/static-board-display.tsx +++ b/src/components/board/static-board-display.tsx @@ -80,9 +80,11 @@ export const StaticBoardDisplay = memo(function StaticBoardDisplay({ const label = useMemo(() => { if (!message) return emptyLabel; if (previewLabel !== undefined) return previewLabel; - const text = messageToText(message); + // `deviceType` matters: on Note a `°` draws as a heart, and the name has to + // say what the tiles draw. + const text = messageToText(message, deviceType); return text ? messageLabel(text) : NO_TEXT_LABEL; - }, [message, previewLabel, messageLabel, emptyLabel]); + }, [message, deviceType, previewLabel, messageLabel, emptyLabel]); // Seam gap: additional left/top margin applied at Note physical boundaries const seamGap = size === "sm" ? "6px" : size === "md" ? "8px" : "10px"; diff --git a/src/lib/board-characters.ts b/src/lib/board-characters.ts index b27be7f4..0e2f1832 100644 --- a/src/lib/board-characters.ts +++ b/src/lib/board-characters.ts @@ -210,6 +210,18 @@ export function parseLine(line: string, maxTokens: number = Infinity): BoardToke return tokens; } +/** + * On Note, the degree symbol (code 62) displays as a heart. + * + * Shared by {@link messageToGrid} and {@link messageToText} rather than written + * out twice: the tiles and the accessible name have to agree about what the + * board draws, or a Note board shows ♥ while announcing "degree" (WCAG 1.1.1). + */ +function applyDeviceSubstitution(token: BoardToken, isNote: boolean): BoardToken { + if (isNote && token.type === "char" && token.value === "°") return { type: "char", value: "♥" }; + return token; +} + /** * Convert a message string to a rows×cols grid of tokens. * Lines are split on `\n`, truncated/padded to the grid, and on the Note @@ -235,13 +247,7 @@ export function messageToGrid( // Fill to cols width for (let col = 0; col < cols; col++) { if (col < tokens.length) { - const token = tokens[col]; - // On Note, degree symbol (code 62) displays as heart - if (isNote && token.type === "char" && token.value === "°") { - rowTokens.push({ type: "char", value: "♥" }); - } else { - rowTokens.push(token); - } + rowTokens.push(applyDeviceSubstitution(tokens[col], isNote)); } else { rowTokens.push(BLANK_TOKEN); } @@ -268,15 +274,23 @@ export function messageToGrid( * (they occupy a cell but say nothing), lines join with a space, and runs of * whitespace collapse so a half-empty board does not announce a long silence. * + * `deviceType` is taken for the same reason {@link messageToGrid} takes it: on + * Note, `°` draws as a heart, and a name that said "degree" would describe + * something the board is not showing. + * * Returns `""` for a message that draws no text at all — a color-only board — * so callers can fall back to a generic name instead of a dangling prefix. */ -export function messageToText(message: string): string { +export function messageToText(message: string, deviceType: string = "flagship"): string { + const isNote = deviceType === "note"; return message .split("\n") .map((line) => parseLine(line) - .map((token) => (token.type === "char" ? token.value : " ")) + .map((token) => { + const drawn = applyDeviceSubstitution(token, isNote); + return drawn.type === "char" ? drawn.value : " "; + }) .join(""), ) .join(" ")