Repository navigation
Accessibility: keyboard nav, landmarks, screen-reader board summary, offers, clocks and chat - #2377
Draft
nnickoloff1234 wants to merge 26 commits into
Draft
nnickoloff1234 wants to merge 26 commits into
nnickoloff1234 wants to merge 26 commits into
Conversation
Ordinary HTML semantics on the site chrome. No board code, no chessgroundx, no
layout CSS, and nothing behind a special mode -- every change benefits sighted
keyboard users as much as screen-reader users.
Measured throughout with Chrome's computed accessibility tree over CDP (the
artifact a screen reader is actually handed), and heard with Orca.
NAVIGATION WAS UNREACHABLE WITHOUT A MOUSE
The six header menus opened on :hover only, so 15 destinations -- Forum, Teams,
Tournaments, Studies, Import game and 10 more -- could not be reached by
keyboard at all, and never by touch. Measured on the lobby: 8 of 31 focusable
elements reachable, now 31 of 31. WCAG 2.1.1 Level A.
- :focus-within beside :hover on the submenus.
- The six section titles become real disclosure buttons carrying
aria-expanded, so a screen reader is told whether a menu is open. They were
links, and a browse-mode user has exactly one verb -- activation -- so a
single control must either navigate or toggle; aria-expanded on something
that navigates is a lie. No destination is lost: each section's URL is also
its submenu's first item. topNav.ts adds one delegated listener, Escape to
close with focus returned, and mouseleave to match hover's old behaviour.
- The three header panel buttons (settings, notifications, challenges) gain
aria-expanded/aria-controls, routed through one helper so the attribute
cannot drift from the .shown class.
LANDMARKS
lichess ships banner + navigation + main on every page in both normal and blind
mode; we had no navigation landmark anywhere, and no main on the board pages.
- <div class="topnav"> becomes <nav aria-label="Site">. The one selector that
qualified it with `div` is un-qualified, or the section hover background
dies silently.
- <main> on the analysis, editor and puzzle views. These are client-rendered,
so they never inherited the template's <main>: a screen-reader user had
nothing to jump to and had to arrow past the header on every page load.
- The lobby's and the players page's two <aside>s are named, so they no longer
announce as "complementary, complementary". lichess names none of theirs.
HEADER FIXES FOUND WHILE MEASURING
- The Board Settings variant select had NO accessible name: its <label> was
the only one in the client written `props: { for: ... }` instead of
`attrs:`. Snabbdom's props module assigns elm[key] = value, and a label's
reflecting property is htmlFor, not for -- so no `for` attribute was ever
emitted and label.control was null. A 59-option combobox announced as
nothing. WCAG 4.1.2.
- Tabbing to the header search input left the header reading
"SearchTest-Soldier..." until a page reload. overflow:hidden still creates a
scroll container: the input is focusable but clipped outside the collapsed
50px, so focus scrolls it into view and leaves scrollLeft at 36-60, pushing
the magnifier out of the aperture. Nothing reset it. :focus-within now
expands the bar as .active does, so the content fits and there is nothing to
scroll. Mouse behaviour is unchanged.
Gates: yarn lint, typecheck, md, test (94 suites, 775 tests); Python
unittest 1312 OK and pytest tests/test_simul.py 40 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nnickoloff1234
marked this pull request as draft
September 28, 2026 08:25
Drop the untranslated "Site" label from the header nav: it is the only navigation landmark on every page, so "navigation" is unambiguous. Rename the side-panel labels to strings that already exist in the catalogs: players "Online" and "Leaderboard", lobby "Create a game". The lobby's "What's up" stays, as there is no existing string for that panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every board page now carries a visually hidden summary in the shape of lichess's blind mode: the position as text (one line per piece type, per colour), the pockets, the last move and the game status. The last move and the status are live regions, assertive on round pages and polite on analysis and puzzle pages, so an opponent's move is spoken without the player looking for it. BoardSummaryView (client/accessibility/boardSummary.ts) is owned by every GameController, so single-board pages get one and each board of a two-board page gets its own, headed "Board A"/"Board B". On two-board pages the status is reported on the viewer's own board only. Squares follow the variant's notation for shogi and janggi. Piece names now come from client/pieceNames.ts: the variant's own table, then its piece family's (chess, xiangqi), then the letter. Tables hold English marked with the new N_() and are translated when shown; the old pocket.pieceNames ran _() before translations loaded. Names are filled for shogi, shogun, seirawan, capablanca, capahouse, grand and orda, and community variants keep their authors' names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…chat
- <html lang> from the session locale, with the underscore of codes such
as zh_CN turned into the hyphen the attribute expects.
- The draw, takeback, rematch and correspondence-confirm offers answer
through real buttons named Accept/Decline/Cancel/Confirm, and the offer
area is a polite live region, so an arriving offer is read out.
- A takeback back to the start now rebuilds the move list; it kept
showing the taken-back moves.
- The move-list buttons were named by their icon font's glyph ("p",
"l", "n"...); the icons are hidden from readers and the buttons are
titled First/Previous/Next/Last move.
- The board summary gains a Time section: each clock in words, read on
request (role="timer"), fed by a new Clock.onRender hook. The visible
clock, which read as "05", ":", "23", is hidden from readers.
- Chat, everywhere: a real list whose new messages are announced
politely, as on lichess; the input is named "Chat"; history resent on
load or reconnect is filled with the list silenced. Bughouse preset
messages carry the preset's label, from one presetLabel() shared with
the preset buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, tournaments
- Game pages get a hidden h1 (the tab title) and h2s for Moves, Actions
and Chat, so a screen reader can jump to them.
- Lobby seek rows: the colour icon is a button ("Join: Alice, 5+3,
Crazyhouse" / "Cancel: …"); its click reaches the row's own handler.
- Tabs on the lobby, analysis and two-board pages are <button role=tab>,
as on lichess: every tab a Tab stop, Enter/Space native, no key
handling. The two-board tabs lose their positive tabindex values.
- The mobile menu's hamburger is a named button with aria-expanded, and
its closed links are hidden rather than only moved off-screen, so Tab
no longer walks through them. The analysis Menu reports its state.
- Focus rings removed for everyone now go only after a mouse click
(:focus:not(:focus-visible)): game controls, icon buttons, sliders,
tab-panel text areas.
- Settings: the 323 board-theme and piece-set radios are named; header
and page aria-labels are translatable.
- One dismissal for all header dropdowns (nav menus, login, Settings,
Notifications, Challenges): click outside closes, Escape closes and
returns focus. Replaces three copies; the panels gain Escape.
- Tournaments: standings and player-game rows get buttons, the "×"
closes are named buttons, the top game is a real link, the pager
buttons are named.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Labels: the arena form's "Byoyomi periods" points at its select; the memory game's background radios are named (they used label=, which is no attribute on <input>); the lobby's rating-range sliders are named; the editor's and the lobby's FEN fields are named "FEN". - The editor's control strip, the analysis actions, the engine's "Go deeper", the puzzle's Hint / View the solution / Continue training and the zen-mode exit were <a> without href: not focusable, deaf to Enter. They get href="#" and preventDefault() in their handlers (as the forum's cancel link does); measured, buttons would have changed their look and href="#" does not. A keyboard user can now leave zen mode. - Puzzle feedback goes to the board summary's polite status line, so a screen reader hears "Best move!", "That's not the move!" and the result; puzzle moves now update the summary's last move too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Forum relocate/delete and round-robin modals are native <dialog>s via shared modalDialogHooks: focus moves in, Escape closes, focus returns. - alertDialog is a native modal like confirmDialog, so it shows above another modal. - Forum post buttons stay reachable by Tab (opacity, not visibility). - Engine lines are keyboard buttons (pvLine); focus survives the placeholder between positions. - Tournament player panel stats are a polite live region; top-games links are named "Open game: white – black". - Names for the forum edit and reply boxes, placeholder-only fields, tournament/team form fields, puzzle vote links, decorative images and profile trophies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every page starts with a two-item list, visible only on focus: "Skip to main content" (to a #main-content target in base.html) and a link to the new /page/accessibility guide, a plain translatable page describing the board summary, move navigation and what is not available yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nnickoloff1234
force-pushed
the
accessibility-semantics
branch
from
October 11, 2026 09:56
381de12 to
0fb38b2
Compare
- Docs: one h1 per terminology and Battle of Ideologies page (sections
move down a level); Dragon's piece heading no longer skips a level.
- Heading levels follow the content on the patron and features pages
and in the create-game dialog; CSS keeps every converted element's
look. The closed-account page and two admin pages get an h1.
- Rules pages: the variant icon at the start of each title gets an
empty alt (the heading was read as "Crazyhouse960 Crazyhouse 960"),
and run-together diagram labels ("HorseDiagram", "image") read as
words ("Horse movement").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # client/analysis/analysisPage.ts # static/docs/battleofideologies.md # static/docs/melonvariant.md
- Headings where a screen-reader user lands: a hidden h1 on the puzzle,
editor, stats, games and players pages; the lobby gets a heading per
section (What's up, Timeline, Challenges, Variants, Create a game,
Leaderboard, Tournament winners, Puzzle of the day) and the players
page one per variant card. Visible titles are marked with
role="heading", so nothing moves.
- The games page's live-game cards could only be clicked: they are
keyboard links now ("Open game: white – black, variant, 5+0"). The
game grid is no longer in an unnamed <aside>.
- The stats page gets a <main> landmark.
- Editor: the side-to-move select is named, and the two queenside
castling boxes are "White O-O-O" / "Black O-O-O".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round Abort/Draw/Resign and two-board Draw/Resign hide their icon glyphs; Give 15 seconds, the engine switch, the FEN fields and the crosstable result links get names; the two-board move list matches the single-board one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
replayingChat() turns the list's aria-live off before a history refill; history lines are added with a flag, and every other message turns it back to polite. No timer, so a message arriving right after a reconnect is no longer lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A history line sets the list's aria-live off itself before it is added, so the separate call before each refill and its test mock are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srHeading/pageHeading go to accessibility/headings.ts, pvLine/pvLinePending to a new common/engineView.ts shared by both analysis pages. The ariaLabel parameter on toggleSwitch is reverted: one caller did not justify it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local hidden() helpers in the single- and two-board move lists become ariaHiddenAttr() in accessibility/aria.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The heuristic that turned file names into words is gone; each radio's aria-label is its board file or piece CSS name as it stands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tables are still translated again when shown, so one marker convention is enough; i18n.ts is back to upstream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clock.ts's hiddenFromReaders() and the hand-written aria-hidden attrs in the round controls, tournament pager and two-board round controls now use the shared helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Accessibility work prompted by a blind user who wrote in. Two parts: ordinary HTML semantics on the site chrome, and a screen-reader summary of the board on every game page. No chessgroundx changes, no layout changes, and no special mode — everything is always on, and the parts sighted users can see benefit keyboard users as much as screen-reader users.
Everything was measured with Chrome's computed accessibility tree over CDP — the artifact a screen reader is actually handed, rather than the DOM — and listened to with Orca.
Navigation was unreachable without a mouse
The six header menus opened on
:hoveronly, so 15 destinations — Forum, Teams, Tournaments, Studies, Import game and 10 more — could not be reached by keyboard at all, and never by touch. Measured on the lobby: 8 of 31 focusable elements reachable, now 31 of 31. WCAG 2.1.1 Level A.aria-expandedis true (:hoverstays as the pointer preview), so the screen and the screen reader cannot disagree.aria-expanded. They were links, and a browse-mode screen-reader user has exactly one verb — activation — so a single control must either navigate or toggle, andaria-expandedon something that navigates is a lie. No destination is lost: each section's URL is already its submenu's first item (Play→/?anyis also "Create a game").client/topNav.tsadds one delegated listener, Escape to close with focus returned to the button, andmouseleaveso a click followed by a mouse-out behaves the way hovering always did.aria-expanded/aria-controls, routed through a single helper so the attribute cannot drift from the.shownclass.Landmarks
lichess ships
banner+navigation+mainon every page in both normal and blind mode — landmarks are not a blind-mode feature there. We had nonavigationlandmark anywhere, and nomainon the board pages.<div class="topnav">→<nav>. It is the only navigation landmark on every page, so it needs no name. The one CSS selector that qualified it withdivis un-qualified in the same commit, or the section hover background dies silently.<main>on the analysis, editor and puzzle views. These are client-rendered, so they never inherited the template's<main>: a screen-reader user had nothing to jump to and had to arrow past the header on every page load. The round view already emitted one, so this is copying what existed.<aside>s are named, so they no longer announce as "complementary, complementary". The names reuse existing translations ("Create a game", "Online", "Leaderboard"); only "What's up" is new.Two header bugs found while measuring
<label>was the only one inclient/writtenprops: { for: ... }instead ofattrs:. Snabbdom'spropsmodule assignselm[key] = value, and a label's reflecting DOM property ishtmlFor, notfor— so noforattribute was ever emitted andlabel.controlwasnull. A 59-option combobox announced as nothing, while looking perfectly labelled on screen. WCAG 4.1.2.SearchTest-Soldier…, magnifier gone.overflow: hiddenstill creates a scroll container:#search-inputis focusable but clipped outside the collapsed 50px, so focus scrolls it into view and leavesscrollLeftat 36–60, pushing.search-iconout of the aperture. Nothing ever reset it.:focus-withinnow expands the bar exactly as.activedoes, so the content fits and there is nothing to scroll. Mouse paths measured identical before and after.The board, for screen readers
The board itself is pixels: chessgroundx exposes no text, so a screen reader found nothing on a game page and an opponent's move was never announced. Every board page now carries a visually hidden summary in the shape of lichess's blind mode, rendered beside the board rather than inside it:
76) and janggi (83), matching what the move list already says.BoardSummaryView(client/accessibility/boardSummary.ts) is owned by everyGameController, so a single-board page gets one and each board of a two-board page gets its own, headed "Board A" / "Board B", with the game status on the viewer's own board only so a result is announced once. Colour names are the variant's own (Red, Gold, Blue).One source for piece names
client/pieceNames.ts—pieceName(variant, role, color)tries the variant's own table, then its piece family's (chess, xiangqi), then falls back to the letter. Table entries use_()like everywhere else and are translated again with_()when shown, because the tables are built before translations load. That also fixes the oldpocket.pieceNames, which were translated only at load and so stayed English. Filled from the rules pages for shogi, shogun, seirawan, capablanca, capahouse, grand and orda; community variants keep their authors' names. The bughouse chat presets use the same function.Game controls, clocks and chat
<html lang>from the session locale (zh_CNbecomeszh-CN), so a translated page is read by a synthesiser in the right language.<div>s, unreachable by keyboard. They are buttons now, named Accept / Decline / Cancel / Confirm, styled exactly as before (measured). The offer area is a polite live region, so an arriving offer is read out.titlein the name computation. The icons are hidden from readers and the four arrows get titles (First / Previous / Next / Last move).role="timer", so it is read when reached and never spoken as it ticks. AClock.onRenderhook feeds it. The visible clock, which read as "05", ":", "23", is hidden from readers.aria-live="off"while history refills it and back topoliteby the next live message (no timer). Bughouse preset messages, previously an empty CSS icon to a reader, carry the preset's label from onepresetLabel()shared with the preset buttons.Headings, the lobby, tabs, menus, focus rings, settings and tournaments
h1(the page title) andh2s for Moves, Actions and Chat, so a screen reader can jump straight to them. Lichess's normal game page has none; its blind mode is organised by them.<button role="tab">: every tab is a Tab stop, Enter/Space work natively, no key handling. The lobby tabs were unreachable by Tab, and the two-board tabs (and panels) carried positivetabindexvalues that hoisted them ahead of the whole page.<div>; it is a named button reportingaria-expanded. Its closed links were only slid off-screen, so Tab walked through 25 invisible links before reaching the page; they are hidden while closed now, still animating out. The analysis "Menu" button reports its state too.:focus:not(:focus-visible)), the patternstudy.cssalready uses.aria-labels that were hard-coded English are translatable.registerHeaderDropdown()inheaderPanel.tsreplaces the three copies. The panels gain Escape (closing and returning focus), which they never had.Labels, action links, dialogs and engine lines
h('a')with a click handler and nohref(the board editor's whole control strip, analysis actions, "Continue training" and the puzzle votes, two-board and zen controls) could not be reached by keyboard. They gethref="#"andpreventDefault(), the minimal change that leaves their look untouched (measured).<dialog>s opened withshowModal()through one sharedmodalDialogHooks: focus moves in and stays, Escape closes, and focus returns to the opener.alertDialogbecame a native modal exactly asconfirmDialogdid (7c6b214), so an alert raised from inside a modal shows above it instead of behind it. Visuals unchanged (measured).visibility: hiddenuntil hover, so Tab skipped them;opacity+:focus-withinkeeps them identical for the mouse and reachable by keyboard.pvLine()), one Tab stop per line, named by the line itself ("1. e4 c5 2. Nf3 …"). The "-" placeholder between positions is the same element, so Enter, Enter, Enter walks the engine's line while the board summary announces each move. Not a<button>, which would clip the line's ellipsis.display: none(now.sr-only); placeholder-only fields (forum search, inbox, tournament/team forms) carry their text as a label too; the editor FEN and lobby fields are labelled.alt=""(readers announced their file names); profile trophies get their name asalt.Every converted element was measured in place against its old form (swapped back and compared) and came out identical; the header controls, tabs, seeks and tournament rows were driven with real keys.
Skip link and an accessibility guide
#main-contenttarget placed once inbase.html, so every page has it without editing each template./page/accessibility: an ordinary translatable page (the same shell as the forum etiquette page, no JavaScript) describing the landmarks, the board summary section by section, moving through a game with the arrow keys, offers, engine lines and chat, and saying plainly that keyboard moves are not available yet. The two links are a list, so a reader announces "list, 2 items". lichess does the same with its blind-mode tutorial page.Headings and image labels
h1per page. The terminology page (7 languages) made every section anh1, so pressing1cycled through six "page titles"; its sections areh2now. The Reports and Public chats admin pages had noh1; they get a hidden one named as in the admin menu (so doesclosed.html, which no route currently renders).h6(now a paragraph) and its questionsh3s under theh1(nowh2); the features table's "All registered accounts" column label was anh3inside a<th>(now plain text); Dragon Chess's piece heading and the create-game dialog's "A.I. Level" each sat two levels below their parent. CSS keeps every converted element's look (measured against the old tags in the same page).h1, and the icon's alt repeated the title: Chrome named the heading "Crazyhouse960 Crazyhouse 960". The icon is decoration beside the name, so its alt is empty (296 markdown files).Page headings, game cards, and names found by verification
h1and is organised byh2s, hidden where the page has no visible title: Lobby (What's up › Timeline, Challenges, Variants, Create a game; the Leaderboard, Tournament winners and Puzzle of the day titles become level-2 headings in place), Players (Online, Leaderboard, one level-3 heading per variant card), Games, Editor, Puzzle and Stats (which also gains its<main>). All strings reused.Verification
Board summary: verified live on crazyhouse round (between two windows: opponent moves, a drop, resignation) and analysis, a live four-player bughouse game, finished bughouse games and the bughouse analysis board, plus every filled variant including promoted pieces. Gates on this branch:
yarn lint,yarn typecheck,yarn md,yarn test(101 suites, 832 tests).Game controls, clocks and chat: verified live with real keys (Enter declined a draw, Space accepted a takeback, Enter accepted a rematch), a 3-minute game with both windows' clocks agreeing through a flip, a chat message and three bughouse presets arriving as named list items in a polite live list, and
langserved asen,de,zh-CN,gl-ES.Labels, dialogs and engine lines: verified live with real keys (forum delete modal on a live post, engine lines over three moves, puzzle vote by keyboard) and from Chrome's accessibility tree; the round-robin modal through
test_gui.py::test_round_robin_full_browser_flow. Gates:yarn lint,yarn typecheck,yarn md,yarn test(830 tests).Page headings and the verification fixes: a live crazyhouse game between two windows, followed over AT-SPI with Orca's event log (joined by Enter on the seek, the opponent's moves and the result announced, a draw offer declined by Enter, chat, the Draw confirmation focused); engine lines walked by Enter on the two-board analysis page too; the layout matrix (286 rows) against the same commit with this PR reversed shows no difference;
yarn lint,yarn typecheck,yarn md,yarn test(836 tests, after merging master).Headings and image labels: heading outlines and names read from Chrome's accessibility tree on the patron, features, terminology, Dragon, Crazyhouse 960, shogi, chak and cannon shogi pages;
yarn lint,yarn typecheck,yarn md,yarn test(830 tests).Skip link and guide: driven with real keys on the lobby, analysis, forum, players and guide pages (Tab shows the skip link, Enter then Tab lands on the page's first control); header and content positions measured unchanged; Python
unittest1485 OK,pytest tests/test_simul.py40 passed, ruff and pyrefly clean.Header work:
yarn lint,yarn typecheck,yarn md,yarn test(94 suites, 775 tests); Pythonunittest1312 OK;pytest tests/test_simul.py40 passed. Landmark and focus counts re-measured from the accessibility tree after each change, and the nav menus heard in Orca ("Play collapsed button" → Enter → "expanded").🤖 Generated with Claude Code