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

22 KiB
Raw Blame History

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, 64role='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 hierarchysrc/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 targetsstyles.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).