Files
5chan/docs/agent-runs/codebase-audit-2026-04-23/05-accessibility.md
T
Tommaso CasaburiandGitHub 5dc5408a15 fix(codebase audit): preserve cleanup without regressions
Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
2026-04-24 15:48:07 +07:00

91 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Accessibility Audit
Static review of `src/components/` and `src/views/` on 2026-04-23. Read-only pass; no browser/axe verification was performed. Findings focus on the primary interactive patterns (posting, replying, voting/subscribing, navigating threads, opening modals) over niche settings screens.
## Summary
5chan leans heavily on non-semantic elements wrapped in `role="button"` plus `tabIndex={0}` and a hand-rolled `onKeyDown` that handles Enter/Space. This pattern is applied consistently enough to be keyboard-operable in most places, but it is brittle and leaks into cases where native `<button>` would trivially solve the problem. Eighty-nine uses of `role='button'` were found across the tree.
The most material issues are:
1. **Modals are not dialogs.** None of the custom modals (`reply-modal`, `settings-modal`, `disclaimer-modal`, `directory-modal`, `create-board-modal`, `boards-bar-edit-modal`, `challenge-modal`) set `role="dialog"` / `aria-modal="true"` or label the dialog. Several also lack a focus trap and do not return focus to the trigger. Floating-UI-based popovers (post menu, tooltips) are the well-done exceptions.
2. **Form controls have no programmatic labels.** The primary post and reply forms render inputs with visible `<td>` text or placeholders but no `<label htmlFor>`, `<label>` wrap, or `aria-label`/`aria-labelledby`. Screen readers announce these as unlabeled "edit text" fields. Exactly one `htmlFor` exists in the whole tree (`boards-bar-edit-modal`).
3. **No landmarks, no h1, no skip link.** There is no `<main>`, `<header>`, or `<nav>` in the main app layout (only `<footer>`). No view except `/blotter` renders an h1. There is no skip-to-content link.
4. **Icon-only controls rely on `title` for their accessible name.** The close buttons on most modals are empty `<button>` elements styled as an X with only a `title='Close'`. `title` is not a reliable accessible name and is unusable on touch.
5. **Nested interactive elements.** `CatalogButton` and `ReturnButton` render `<button><Link>…</Link></button>`, which is invalid HTML and confuses assistive tech and keyboard nav.
6. **`<label>` used without a form control** (settings-modal categories) — labels wrap text only; the click target is the label itself, not an input, so the semantics are misleading.
Heading hierarchy is skipped in most views (jump straight to h2/h3). Images generally carry `alt=''` where decorative, which is correct; no missing `alt` was found. Keyboard traps were not observed.
## Findings
### Critical
- **src/components/reply-modal/reply-modal.tsx:290-310** — Core reply surface. The outer `<animated.div>` is not a dialog: no `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, or `aria-label`. *Fix:* `<div role="dialog" aria-modal="true" aria-labelledby="reply-modal-title">` and put the title ("Reply to No. 12345") inside an element with that id.
- **src/components/reply-modal/reply-modal.tsx:313, 324, 333** — Name, link, and comment inputs have placeholders but no associated `<label>`, `aria-label`, or `aria-labelledby`. Screen readers announce only "edit text". *Fix:* wrap each input in `<label>` with visible text or add `aria-label={t('name')}` / `t('link')` / `t('comment')`.
- **src/components/reply-modal/reply-modal.tsx:302-309** — Close button is an empty `<button>` with only `title='close'`. `title` is not exposed as an accessible name reliably; touch users never see it. *Fix:* `aria-label={t('close')}` (and keep the visual X via CSS background).
- **src/components/post-form/post-form.tsx:152-165, 183-189, 207, 214-229, 258-263, 272-282** — The posting form (subject, name, comment, link, file, spoiler, board-select) has no label associations. Labels sit in adjacent `<td>` cells but are not wired via `htmlFor`/`id`. The `<select>` for board has no accessible name. *Fix:* give every input a stable `id` and add `<label htmlFor={id}>{t('name')}</label>` on the sibling `<td>`, or wrap the input in the label. Same for the `<select>`.
- **src/components/post-form/post-form.tsx:207 & src/components/reply-modal/reply-modal.tsx:333** — The comment `<textarea>` (the primary text entry in the app) is unlabeled. *Fix:* `aria-label={t('comment')}` at minimum.
- **src/components/post-form/post-form.tsx (no `<form>` wrapper) & reply-modal.tsx (no `<form>` wrapper)** — Neither posting surface is inside a `<form>`, so Enter does not submit and assistive tech cannot navigate by form. *Fix:* wrap fields in a `<form onSubmit={...}>` with a submit button, even in the `<table>` layout.
- **src/components/disclaimer-modal/disclaimer-modal.tsx:34-65** — The backdrop is `role='button' tabIndex={0}` *and* wraps the dialog content; the dialog itself has no `role="dialog"`, no `aria-modal`, no focus trap, and no initial focus. Accept/Cancel are reachable only by tabbing through every control on the (backgrounded) page. *Fix:* move the backdrop to a non-focusable `<div aria-hidden="true">` handled via `onMouseDown`; give the inner dialog `role="dialog" aria-modal="true" aria-labelledby="disclaimer-title"`; set initial focus to the Accept button on open; restore focus to the trigger on close.
- **src/components/disclaimer-modal/disclaimer-modal.tsx:49, src/components/create-board-modal/create-board-modal.tsx:34, src/components/directory-modal/directory-modal.tsx:36, src/components/challenge-modal/challenge-modal.tsx:369** — Empty close `<button>` with `title='Close'` only. No accessible name, no text. *Fix:* add `aria-label={t('close')}`.
- **src/components/settings-modal/settings-modal.tsx:106, 110, 114** — Settings overlay and close "button" are `<div>`/`<span>` with `role='button'` instead of real `<button>`. The modal itself also has no `role="dialog"`/`aria-modal`/`aria-labelledby`. *Fix:* use `<button>` elements and wrap the modal in `<div role="dialog" aria-modal="true" aria-labelledby="settings-title">`.
### High
- **src/components/board-buttons/board-buttons.tsx:83-86, 154-157** — `<button className='button'><Link to=...>{t('catalog')}</Link></button>` and the same for Return. Nested interactive elements — invalid HTML, double-focusable, announced twice, and the button's `onClick` never fires because the Link swallows the click. *Fix:* drop the outer `<button>` and style the `<Link>` directly (`<Link className='button' to=...>`), or use `navigate()` from a real `<button>`.
- **src/components/settings-modal/settings-modal.tsx:119-160** — Category toggles use `<label onClick={...}>` without wrapping or associating any input. A `<label>` with no control is inert to assistive tech and has misleading semantics. *Fix:* replace with `<button type="button" aria-expanded={showInterfaceSettings} aria-controls="interface-settings-panel" onClick={...}>`.
- **src/components/comment-media/comment-media.tsx:73-86, 107-120, 123-136, 172-185, 217-233, 237-253, 321-335, 348-366, 387-405** — `<img role='button' tabIndex={0} onClick>` for expanding/collapsing media. Keyboard works, but `alt=''` + `role='button'` gives an unnamed button to a screen reader. *Fix:* add `aria-label={t('expand_image')}` / `t('collapse_image')` on the interactive image, or wrap the `<img alt=''>` in a real `<button aria-label=...>`.
- **src/components/comment-media/comment-media.tsx:92-103** — `<video>` with `role='button' tabIndex={0} onClick/onKeyDown`. A `<video>` is not a button; hijacking its semantics confuses AT, and native video controls are already focusable. *Fix:* render the thumbnail as `<img>` + a sibling/wrapper `<button aria-label>` instead of abusing the video element.
- **src/components/catalog-search/catalog-search.tsx:105-115** — Search input has no `<label>`/`aria-label`, and the closing "✖" is a `<span role='button'>` with a literal Unicode glyph as its name. *Fix:* `aria-label={t('search')}` on the input; `<button type='button' aria-label={t('close_search')}>✖</button>` (or keep it a span but add `aria-label`).
- **src/components/style-selector/style-selector.tsx:32-40** — The theme `<select>` has no label. Announced as "combo box" with no name. *Fix:* `<label>{t('style')}:<select aria-label={t('style')}>` (or pair with the `{t('style')}` label already rendered in footer.tsx via `htmlFor`).
- **src/views/home/home.tsx:37-45** — Board-jump `<input>` on home has no label, just a placeholder, and the `<form>` wraps it without any accessible name on either. *Fix:* add `aria-label={t('enter_board_address')}` on the input and `aria-label={t('search_boards')}` on the form.
- **src/components/post-desktop/post-desktop.tsx:1130-1141** — Thread "hide" toggle is a `<span role='button'>` with no text content, no `aria-label`, and state conveyed only via a class name. *Fix:* `aria-label={hidden ? t('unhide_thread') : t('hide_thread')}` and `aria-pressed={hidden}`; prefer `<button type='button'>`.
- **src/components/post-desktop/post-desktop.tsx:1197-1209, post-mobile has the equivalent** — "Show/hide omitted replies" toggle is a `<span role='button'>` with no accessible name (icon-only via CSS). *Fix:* add `aria-label={showOmittedReplies[cid] ? t('hide_omitted_replies') : t('show_omitted_replies')}` and `aria-expanded`.
- **src/components/post-desktop/post-desktop.tsx:388-404 & src/components/post-mobile/post-mobile.tsx:315-331** — User ID highlight uses `<span role='button' tabIndex={0}>` with only the truncated ID as visible text, `title={t('highlight_posts')}`, and inline `backgroundColor/color`. Contrast of the random user-ID colors is not guaranteed against the selected theme, and `title` is not a reliable accessible name. *Fix:* `aria-label={t('highlight_posts_by_user', {id})}` and verify colour-pair contrast in the palette generator (or render on a neutral chip).
- **src/components/post-desktop/post-desktop.tsx:430-444 & post-mobile 389-404** — The "reply-to-post" number (clicking a post number to quote it) is a `<span role='button'>` on `onMouseDown` (not `onClick`) — mouse-only discoverability and a non-standard activation for keyboards. *Fix:* use `onClick`/keyboard handler consistent with the rest of the app, or a real `<button>`.
- **Modal focus management** (all modals) — Most modals do not trap focus or return focus to the trigger on close. `reply-modal` autofocuses the textarea on open (good) and listens for Escape (good), but still returns focus to `<body>` on close. *Fix:* store `document.activeElement` on open and `.focus()` it on close; add a focus trap (simplest: `FocusTrap` from `@floating-ui/react` or `focus-trap-react`). `post-menu-desktop` already uses `FloatingFocusManager` — that pattern can be reused.
### Medium
- **src/components/comment-media/comment-media.tsx:266-280** — Mobile media "close" is a `<span className='button' role='button'>`. Pure text button rendered as span. *Fix:* use `<button className='button'>`.
- **src/components/post-desktop/post-desktop.tsx:674-688 & 694-708** — Inline "close"/"open" toggles for expanded media use `<span role='button'>`. *Fix:* `<button type='button' className={styles.closeMedia}>`.
- **src/components/boards-bar/boards-bar.tsx:226-244, 249-263, 265-279, 284-293, 396** — Five separate `<span role='button'>` nav triggers ("...", "edit", "create board", toggle search). Visible text is present and keydown handlers exist, but each is a `<span>` duplicating what a `<button>` gives for free, and each has its own inline `style={{cursor:'pointer'}}` that buttons don't need. *Fix:* convert to `<button type='button' className={styles.temporaryButton}>`.
- **src/components/board-blotter/board-blotter.tsx:45** — Expand/collapse trigger is a `<span role='button'>`. *Fix:* `<button type='button' aria-expanded={...}>`.
- **src/components/catalog-filters/catalog-filters.tsx:154, 198, 258, 279, 293, 322** — Six `role='button'` spans for filter controls (add, save, remove, move). Same pattern. *Fix:* real `<button>`s.
- **src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx:63, 88, 121, 155, 171, 185** — Colour swatch pickers are `<div role='button'>` with no accessible name other than the colour visually. *Fix:* render as `<button type='button' aria-label={`Highlight color ${color}`} aria-pressed={isSelected}>`.
- **src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx:228-269 & post-mobile equivalent** — Items in the post-menu popover use `role='button'` rather than `role='menuitem'`, and the wrapper is not `role='menu'`. Focus is trapped (good) and navigation works via Tab, but arrow-key menu semantics don't apply. *Fix:* use `<button role='menuitem'>` inside `<div role='menu' aria-labelledby={headingId}>` and wire arrow-key navigation, or stop calling it a menu.
- **src/components/markdown/markdown.tsx:95** — Spoiler-text reveal is `<span role='button'>`. *Fix:* `<button type='button' aria-expanded={revealed}>`.
- **src/components/comment-content/comment-content.tsx:204, 246, 268** — Quote/backlink previews are `<span role='button'>`. Fine functionally; would benefit from aria-label describing target post. *Fix:* `aria-label={t('show_reply_preview', {no})}`.
- **src/components/subscriptions-setting/subscriptions-setting.tsx:27, 64** — `role='button'` rows for reordering/removing subscriptions. *Fix:* real `<button>` children with `aria-label`.
- **src/components/post-form/post-form.tsx:257-263** and **reply-modal.tsx:367-370** — Spoiler checkbox is wrapped in `<label>` (good) but the visible text is interleaved with `[` `]` punctuation that screen readers will read as part of the label ("`[ Spoiler? ]`"). Low-impact but untidy. *Fix:* put the brackets outside the `<label>`.
- **src/components/board-header/board-header.tsx:79-100** — The board title is rendered as a styled `<div>`, not an `<h1>`. Every board page therefore has no page-level heading, which is the single most important landmark for screen-reader users. *Fix:* render the board title as `<h1 className={styles.boardTitle}>`.
- **Heading hierarchy** — `src/views/home/home.tsx` jumps straight to `h2` (no `h1`); `src/views/faq/faq.tsx`, `src/views/rules/rules.tsx`, `src/views/pass/pass.tsx`, `src/views/not-found/not-found.tsx`, `src/views/not-allowed/not-allowed.tsx`, `src/views/archive/archive.tsx` all start at `h2`/`h4` with no `h1`. `src/views/blotter/blotter.tsx:23` is the sole view with an `h1`. *Fix:* promote the top title of each view to `h1` and demote the subsequent levels.
- **No `<main>`, `<header>`, or `<nav>` landmarks** in the app shell (only `<footer>` in `src/components/footer/footer.tsx`). Screen-reader users cannot skip to main content. *Fix:* wrap the primary route outlet in `<main id="main-content">`, the boards bar + board header in `<header>` / `<nav aria-label={t('boards')}>`, and add a visually-hidden `<a href="#main-content" className={styles.skipLink}>{t('skip_to_content')}</a>` as the first focusable element in the tree (commonly in `src/App.tsx` or the top-level layout).
- **No skip-to-content link** anywhere in the tree (grep confirms).
- **src/components/comment-media/comment-media.tsx:137-138 & 234-235** — `<audio controls>` / `<video controls>` inside a list of posts. Native controls are focusable but the element itself lacks `aria-label` describing the media. *Fix:* `<video aria-label={t('video_from', {host: getHostname(url)})}>`.
- **src/views/home/home.tsx:46** — `<button className={styles.searchButton}>{t('go')}</button>` — no `type='submit'`, so it defaults to `submit` inside the `<form>` (works) but is brittle. *Fix:* `type='submit'` explicitly.
### Low
- **All modals (`disclaimer-modal`, `create-board-modal`, `directory-modal`, `boards-bar-edit-modal`)** — The backdrop is itself `role='button' tabIndex={0}`, meaning a keyboard user tabs into a full-screen "button" before reaching the dialog content. *Fix:* let backdrop handle `onMouseDown` only and make it `aria-hidden='true'` with no tabindex/role; the Escape key should handle dismissal for keyboard users.
- **src/components/board-buttons/board-buttons.tsx:209-219** — Auto-update is a checkbox inside `<label className={isMobile ? 'button' : undefined}>`. The checkbox has `aria-label={t('Auto')}` but the label wraps both the input *and* the visible "Auto" text, which then gets announced twice. *Fix:* drop the redundant `aria-label` — the wrapping `<label>` already names the input.
- **src/components/post-form/post-form.tsx:154-165** — Display name input uses the user's previous value as `defaultValue`, and `placeholder={!displayName ? 'anonymous' : undefined}` — placeholder-as-label anti-pattern if a screen reader user clears the field. *Fix:* put "Name" as a real visible label (done if the htmlFor fix above is applied).
- **src/components/tooltip/tooltip.tsx** — Tooltips use Floating UI's `useRole('tooltip')`, which is correct, but the trigger wrapper is always a `<span>` regardless of whether the child is interactive. If the child is a non-focusable element, tooltip content is unreachable by keyboard. *Fix:* document that `Tooltip` children must themselves be focusable (or add `tabIndex={0}` on the span when the child isn't interactive).
- **src/components/comment-media/comment-media.tsx:170, 346, 385** — `<img src='assets/filedeleted-res.gif' alt='File deleted'>` is correct. Note for the team: this is the right pattern (meaningful alt). Others (`spoiler.png`, sticky/closed icons) correctly use `alt=''`. No change needed; included as a positive reference point.
- **src/components/post-desktop/post-desktop.tsx:454, 459, 466 & post-mobile** — Sticky/closed/archived indicator icons use `alt=''` + `title={t('sticky')}`. Information is in the `title` only, which screen readers don't reliably read. *Fix:* either use `alt={t('sticky')}` (meaningful icon) or pair the image with a visually-hidden `<span>` sibling.
- **src/components/settings-modal/advanced-settings/advanced-settings.tsx:178** — `<button onClick={() => setShowInfo(!showInfo)}>{showInfo ? 'X' : '?'}</button>` — a question-mark help button with literally "?" as its name. *Fix:* `aria-label={showInfo ? t('hide_info') : t('show_info')}` and `aria-expanded`.
- **src/components/challenge-modal/challenge-modal.tsx:341-352** — Disabled inputs render the challenge subject/content/link (read-only display). Using disabled inputs for display removes them from the tab order, which is fine, but they will not be announced as labelled fields. Consider `<output>` or read-only `<pre>` for clarity. Low-impact.
- **Touch targets** — `styles.closeButton` / `styles.closeIcon` on modals are likely under 40×40 (CSS not fully reviewed), and the `postMenuBtn` (`▶`) is small. *Fix (follow-up):* verify in browser with DevTools; enforce a `min-width/min-height: 44px` on icon-only controls via a shared utility class.
- **Color contrast** — Only obvious static-colour risks are user-ID chips (`src/components/post-desktop/post-desktop.tsx:400`, `post-mobile/post-mobile.tsx:327`), where `userIDBackgroundColor` and `userIDTextColor` come from a generator. Cannot verify contrast statically. *Fix (follow-up):* in the palette generator, enforce WCAG AA (4.5:1) between the generated background and text, or always use a fixed text colour with a constrained background-luminance range.
- **src/components/post-mobile/post-mobile.tsx:806** — `<div onClick={unhide}>` without `role='button'`/`tabIndex`/`onKeyDown`. (`div.postHidden` region — keyboard can't unhide.) *Fix:* use a real `<button>` or add the standard `role/tabIndex/onKeyDown` trio.
- **src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx:62** — Directory-code input has `aria-label='Directory codes'` hardcoded in English instead of using `t(...)`. Accessibility + i18n. *Fix:* `aria-label={t('directory_codes')}`.
## Top 5 Actions
1. **Label every form control in the posting and reply surfaces.** `post-form.tsx` and `reply-modal.tsx` are the two most-used UIs in the app; their inputs (name, subject, comment, link, board-select, spoiler) are currently unlabeled for AT. Add `htmlFor`/`id` pairs (or `aria-label`) and wrap both surfaces in a real `<form>`. This single change fixes the largest category of critical findings.
2. **Promote modals to real dialogs.** Add `role='dialog' aria-modal='true' aria-labelledby=...` to `reply-modal`, `settings-modal`, `disclaimer-modal`, `directory-modal`, `create-board-modal`, `boards-bar-edit-modal`, and `challenge-modal`. Add a focus trap (Floating UI's `FloatingFocusManager` is already used for the post menu — reuse it) and restore focus to the trigger on close. Give every empty close-button an `aria-label={t('close')}`.
3. **Add app-level landmarks + skip link + h1.** In the top-level layout (likely `src/App.tsx`): prepend a visually-hidden `<a href='#main-content'>{t('skip_to_content')}</a>`, wrap the route outlet in `<main id='main-content'>`, wrap the boards bar in `<nav aria-label={t('boards')}>`. In `board-header.tsx`, render the board title as `<h1>`, and in each view that starts at `h2`, promote the top title to `h1`.
4. **Eliminate nested interactive elements in `board-buttons.tsx`.** Replace `<button><Link>…</Link></button>` (CatalogButton, ReturnButton) with either styled `<Link className='button'>` or `<button onClick={() => navigate(...)}>`. This is invalid HTML and currently makes those buttons keyboard-confusing.
5. **Convert the large inventory of `<span role='button'>` / `<div role='button'>` to real `<button>`s.** Start with the most-used ones: hide-thread toggle (`post-desktop.tsx:1130`), show-omitted-replies (`:1197`), post-number quote trigger (`:430`, `post-mobile.tsx:389`), close-media (`:674`, `:694`), `boards-bar.tsx` edit/create/search triggers. Native `<button>` gives focus ring, Enter/Space activation, and `click` semantics for free — no more hand-rolled `onKeyDown` boilerplate. Keep `role='button'` only where the element genuinely cannot be a `<button>` (e.g. the `<img>` click-to-expand in `comment-media.tsx` — which should additionally get an `aria-label`).