# 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`).
