Repository navigation
Audit & fix WCAG 2.2 AA accessibility across the web UI #647
Description
Summary
Audit the LightNVR web UI (web/) against WCAG 2.2 Level AA, fix what fails, and add automated checks so it stays fixed. This issue holds a first-pass static audit of the Preact/JSX source, done on 2026-10-17 at e4fde7a5. It is written so a coding agent can work through it in order.
Scope: every page that
web/*.htmlserves (index,streams,recordings,timeline,investigation,settings,users,system,login,hls) and all components inweb/js/components/preact/**. The C backend is out of scope, except where it serves HTML/meta.
What already works (keep it, and copy these patterns)
web/js/components/preact/common/ModalDialog.jsxis a correct reference dialog. It hasrole="dialog",aria-modal,aria-labelledby/aria-describedby, initial focus, a Tab/Shift+Tab focus trap, Escape to close, and focus restore on close. Use it as the one modal primitive.<html lang>is set statically and also kept in sync with the active locale (web/js/i18n.js:161).- Reduced motion is supported (
web/js/utils/reduceMotion.js,AppearanceTab.jsx). - The timeline playhead is a proper
role="slider"witharia-valuetextand keyboard shortcuts (timeline/TimelineCursor.jsx:407). - A touch-target audit exists:
scripts/audit-touch-targets.mjs, run withnpm run audit. It covers SC 2.5.8. - Every page has
<main id="main-content">. - Most form fields are labelled, either with a wrapping
<label>(for example theFieldhelpers inevents/*Editor.jsx) or withhtmlFor.
Findings
Severity: P1 blocks keyboard or screen-reader users from core tasks. P2 is a clear AA failure. P3 is a best-practice or robustness issue.
P1-1 · Zoom disabled on the main Live View and on HLS pages (SC 1.4.4 Resize Text, 1.4.10 Reflow)
web/index.html:5andweb/hls.html:5containmaximum-scale=1.0, user-scalable=no.- Fix: remove
maximum-scaleanduser-scalable. If pinch-to-zoom on video tiles conflicts with page zoom, handle it inside the tile (MobileTileGestures.jsx,useVideoZoom.jsalready intercept gestures on the tile). Do not disable zoom for the whole page. - Verify: check iOS Safari and Android Chrome. Pinch-zoom on the page chrome must work, and tile gestures must still work.
P1-2 · Most modals are hand-rolled, with no dialog semantics, focus trap, Escape or focus return (SC 2.1.2 No Keyboard Trap, 2.4.3 Focus Order, 4.1.2 Name/Role/Value)
These files render fixed inset-0 overlays outside ModalDialog:
| File | role=dialog | Escape | Focus trap / restore |
|---|---|---|---|
BatchDeleteModal.jsx |
✗ | ✗ | ✗ |
BatchDownloadModal.jsx |
✗ | ✗ | ✗ |
StreamBulkActionModal.jsx |
✗ | ✗ | ✗ |
StreamConfigModal.jsx |
✗ | ✗ | ✗ |
StreamDeleteModal.jsx |
✗ | ✗ | ✗ |
StreamsView.jsx (ONVIF discovery #onvif-modal ~L1791, custom-name modal ~L2018) |
✗ | ✗ | ✗ |
system/ClearLogsModal.jsx |
✗ | ✗ | ✗ |
system/RestartModal.jsx |
? | ✓ | ✗ |
users/DeleteUserModal.jsx |
✗ | ✗ | ✗ |
users/TotpSetupModal.jsx |
✗ | ✗ | ✗ |
users/AddUserModal.jsx, users/EditUserModal.jsx, users/AuditHistoryModal.jsx |
✓ | ✓ | verify |
settings/Go2rtcTab.jsx (effective config, ~L568) |
✗ | ✗ | ✗ |
SetupWizard.jsx |
✗ | ✗ | ✗ |
events/EventDestinationEditor.jsx, events/EventRouteEditor.jsx, fleet/BulkOrganizeModal.jsx |
✓ | ✓ | verify |
UI.jsx (snapshot / video modals) |
verify | ✓ | verify |
- Fix: extend
ModalDialog.jsxso it can host large or scrolling content. Add a generic<Modal>export with asizeprop (md|lg|xl|full) next toConfirmDialog. Then migrate every modal above to it. Delete the ad-hoc backdrops andstopPropagationwrappers. - While migrating, make sure that:
- background content is inert while a modal is open (
inerton#app/siblings, or rely onaria-modalplus the trap); - stacked modals (for example
ConfirmDialogopened fromStreamConfigModal) each trap and restore focus correctly; - the destructive confirm dialogs (
StreamDeleteModal,BatchDeleteModal,DeleteUserModal,ClearLogsModal) put initial focus on Cancel, asConfirmDialogalready does.
- background content is inert while a modal is open (
P1-3 · Click handlers on non-interactive elements (SC 2.1.1 Keyboard, 4.1.2)
StreamsView.jsx:1795andStreamsView.jsx:2022: the modal close control is<span className="text-2xl cursor-pointer" onClick=…>×</span>. It can't be focused and has no name. Replace it with<button type="button" aria-label={t('common.close')}>.live/LiveOperatorNavigator.jsx:339: the expand/collapse<span onClick>(▾/▸) is nested inside a<button>. Nesting an interactive element inside another is invalid, and the toggle can't be reached by keyboard. Restructure it as a tree (role="tree"/treeitemwitharia-expanded, plus Left/Right arrow keys), or make the toggle a sibling<button aria-expanded aria-label>.live/LiveOperatorNavigator.jsx:~326-335: "load location" happens only ononDblClick, so there is no keyboard equivalent. Add an Enter or explicit-button path.recordings/RecordingsGrid.jsx:287: a checkbox wrapper<div onClick=stopPropagation>. Confirm the checkbox inside has an accessible name such as "Select recording <name/time>".- Fix all: add the ESLint rules
jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactionsandjsx-a11y/no-noninteractive-element-interactions(see Tooling).
P1-4 · Header navigation defects (SC 2.1.1, 4.1.2, 2.4.8/1.3.1)
File: web/js/components/preact/Header.jsx
- The "Edit profile" control can't be reached by keyboard.
renderUsername()(~L289) callsrenderNavItemwith nohref, which renders<a>withouthrefand withouttabIndex/role. Anchors withouthrefare not focusable. Render actions as<button type="button">and keep<a>for navigation. - The mobile menu toggle (~L356) has no
aria-expanded={mobileMenuOpen}oraria-controls. Its accessible name doesn't say whether the menu is open or closed. It also usesfocus:outline-nonewith no replacement indicator (see P2-1). - The active nav link has no
aria-current="page". Only the background colour shows which page is active. - Disabled nav item uses
aria-disabledtogether withpointer-events-none, but with nohrefit disappears from the tab order without explanation. That is fine once it becomes a<button disabled>. - Logo (~L317): its
aria-label={t('nav.live')}overrides the visible text "LightNVR v…", which breaks SC 2.5.3 Label in Name. Use anaria-labelthat contains the visible text (for example "LightNVR – Live view") or drop it.
P1-5 · Toast notifications are not announced and are timed (SC 4.1.3 Status Messages, 2.2.1 Timing Adjustable)
ToastContainer.jsx:~107: each toast is a plain<div onClick=removeToast>. The container has norole="status"/aria-live. Screen-reader users never hear success or error messages, including save failures.- Toasts auto-dismiss after 4000 ms (
ToastContainer.jsx:21,44;Toast.jsxshowToastdefaults). Errors disappear before a user can read them. Dismissing only works by clicking the whole toast, with no keyboard path. - Fix:
- Render two persistent live regions in the container:
role="status" aria-live="polite"for info and success, androle="alert"(assertive) for errors. Keep both in the DOM permanently and inject messages into them. - Add a visible close
<button aria-label={t('common.dismiss')}>to each toast. - Pause the timer on hover and focus. Make error toasts stay until dismissed, or at least ≥10 s.
- Respect the reduced-motion setting for the enter and exit animation.
- Render two persistent live regions in the container:
P2-1 · Focus indicators removed (SC 2.4.7 Focus Visible, 2.4.11 Focus Not Obscured, 1.4.11 Non-text Contrast)
- 33 JSX usages of
focus:outline-nonehave no matchingfocus:ring/focus-visible:ring. Find them with:
grep -rn 'focus:outline-none' web/js | grep -v 'focus:ring\|focus-visible:ring'
Examples:Header.jsx:357,timeline/SpeedControls.jsx:57,StreamsView.jsx:1425,1907,1944,1956,2030,WebRTCView.jsx:680,694. - CSS:
css/live.css:419(.live-plan-shape),:465(.live-plan-canvas:focus),:734,:763(.live-plan-cluster),:1474, andcss/investigation.css:639setoutline: none. Check each one for a replacement:focus-visiblestyle. - Fix: add a global rule in
css/base.css:Then remove the bare:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid hsl(var(--ring, var(--primary))); outline-offset: 2px; }
focus:outline-noneutilities, or pair each withfocus-visible:ring-2 focus-visible:ring-[hsl(var(--primary))]. The ring needs ≥3:1 contrast against both light and dark--card/--backgroundand against video tiles. Over video, use a dual-colour (white + dark) ring. - Sticky header, toasts and floating toolbars on the live grid must not cover the focused element (2.4.11). Use
scroll-padding-topset to the header height.
P2-2 · No skip link (SC 2.4.1 Bypass Blocks)
- No page has a "Skip to main content" link, even though
#main-contentexists in everyweb/*.html. - Fix: add a visually hidden, focus-revealed
<a href="#main-content" class="skip-link">as the first child of<body>in each HTML entry point, or inject it fromHeader.jsx. Addtabindex="-1"to<main>so focus moves to it.
P2-3 · Heading structure and page titles (SC 1.3.1, 2.4.2 Page Titled, 2.4.6 Headings and Labels)
- The
<h1>on every page is the logo text "LightNVR" (Header.jsx:330). OnlyAuthGate,EventRoutingView,FleetView,InvestigationView,LoginViewandTimelinePagerender a page-level<h1>. Streams, Recordings, Settings, Users, System and Live have no page heading. - Fix: demote the logo to a non-heading element. Give each view a single
<h1>(it may be visually hidden on Live View). Check thath2/h3levels nest without skipping. document.titleis set only on Live (pages/index-page.jsx:86), Login and AuthGate. Check that eachweb/*.htmlhas a distinct<title>. Better: set a localized"<Page> – LightNVR"title on every page, and in Settings, Users and System also update it when the tab changes.
P2-4 · Icon-only controls on video tiles (SC 4.1.2, 2.5.3, 1.4.13)
HLSVideoCell.jsx(~L974 privacy, ~L1000 detections, ~L1031 PTZ, ~L1059 refresh, ~L1116 fullscreen),MSEVideoCell.jsx(~L963, ~L989, ~L1022, ~L1046, ~L1085, ~L1129–1166) and the matching controls inWebRTCVideoCell.jsx. Many icon buttons rely ontitle=alone for their name.titleis a weak name source: touch and keyboard users never see it, and some screen-reader setups don't read it.- Toggle buttons (show detections, show PTZ, privacy pause) don't expose their state.
- Fix:
- Give every icon-only button an explicit
aria-label(localized viat()), and mark its SVGaria-hidden="true" focusable="false". - Add
aria-pressedto toggles. - Controls that only appear on hover must also appear on focus-within. They must stay visible while hovered or focused and be dismissible (1.4.13).
- Consider extracting a shared
<TileIconButton>used by the HLS, MSE and WebRTC cells so the three can't drift apart.
- Give every icon-only button an explicit
retry-buttonin all three cells: confirm it has a text name that includes the stream name, for example "Retry Front Door".
P2-5 · Live/video status communicated by colour or visuals only (SC 1.4.1 Use of Color, 1.1.1, 4.1.3)
- Stream status (connecting, live, offline, error, recording), detection overlays (
DetectionOverlay.jsx) and health states (HealthView.jsx,settings/HealthTab.jsx,system/*) need audit. Every status must carry a text label or icon plus text, not only a colour. - Connection state changes on a tile ("reconnecting…", "stream offline") should be announced through a polite live region scoped to that tile, or one aggregated region for the grid. Throttle announcements so a 16-camera grid doesn't flood screen-reader output.
<video>elements (HLSVideoCell.jsx:770,MSEVideoCell.jsx:752,WebRTCVideoCell.jsx:1415,timeline/TimelinePlayer.jsx:909,investigation/InvestigationView.jsx:358,UI.jsx:796) need an accessible name such asaria-label="Live video: <stream name>".- Captions (SC 1.2.x): live CCTV video without audio is effectively video-only. Document that it is exempt. Where cameras have audio and playback includes it, track captions as a future item rather than a blocker.
P2-6 · Forms: labels, errors and required fields (SC 1.3.1, 3.3.1, 3.3.2, 3.3.3, 4.1.2)
- 308
<input>and 98<select>against 179htmlFor/for. Many of the rest are wrapped in<label>(fine), but auditStreamConfigModal.jsx(1744 lines),StreamsView.jsx,settings/*Tab.jsx,ZoneEditor.jsxandSetupWizard.jsxfor unlabelled or placeholder-only fields.StreamsView.jsx:1944,1956,2030are likely candidates. - Only 3 uses of
aria-invalidand 3 ofaria-describedbyin the whole app. Validation errors must be:- tied to their field (
aria-invalid="true"plusaria-describedbypointing to the error id); - announced (
role="alert", or focus moved to the first invalid field on submit); - shown as text, not only as a red border.
- tied to their field (
- Required fields: use
required/aria-requiredand show a visible marker that is explained once at the top of the form. - Help text (
Fieldhelpprop inevents/*Editor.jsx) is inside the<label>, so it becomes part of the accessible name. Move help into a sibling element referenced byaria-describedby. - Add
autocompletetokens on login, profile and user forms (LoginView.jsx,users/*Modal.jsx) (SC 1.3.5 Identify Input Purpose):username,current-password,new-password,one-time-code(TOTP).
P2-7 · Custom widgets need full ARIA keyboard patterns (SC 2.1.1, 4.1.2)
Check each against the WAI-ARIA Authoring Practices pattern:
- Tabs: there are 14
role="tab"and 8role="tablist"(Settings, Users, System, Investigation, …). Check rovingtabIndex, Left/Right/Home/End keys,aria-controls↔id, andaria-selected. - Menus: 3
role="menu"and 4role="menuitem", includingcommon/LanguageSelector.jsx. Check that arrow keys move between items, Escape closes and returns focus, and the trigger hasaria-haspopupandaria-expanded.aria-expandedappears only once in the whole codebase (AuditHistoryModal.jsx:54), so every disclosure, accordion, dropdown and collapsible panel is missing it. - Sliders: the second
role="slider"(besidesTimelineCursor) androle="progressbar"need min, max, now and text values. role="application"(find it withgrep -rn 'role="application"' web/js): this switches screen readers out of browse mode. It is only acceptable if the region fully implements its own keyboard model and documents it. Prefer removing it.- Building plan / site plan (
live/LiveBuildingPlan.jsx,live/PlanSketchLayer.jsx,ZoneEditor.jsx): canvas/SVG editors need a keyboard alternative for selecting, moving and deleting shapes, plus a non-visual list of shapes and cameras (SC 2.1.1; 2.5.7 Dragging Movements). Every drag operation needs a single-pointer alternative, such as arrow-key nudges or numeric inputs. - Timeline (
timeline/*): segment selection by drag (SC 2.5.7) needs a non-drag alternative.TimelineSegments.jsx:330has a hard-coded Englisharia-label="Recording timeline"; localize it. - Drag-to-reorder cameras (
useCameraOrder.js): provide keyboard reordering (move up/down buttons or a menu) (SC 2.5.7).
P2-8 · Contrast (SC 1.4.3, 1.4.11)
- Base tokens look OK at first glance.
--muted-foreground: 240 10% 45%on--background: 240 10% 98%is about 5.2:1. But the app layers opacity utilities (bg-muted/50,text-muted-foregroundonbg-muted,hsl(var(--primary)/0.8)hover states,opacity-60disabled items) and user-selectable themes (ThemeCustomizer.jsx,utils/theme-init.js,vite-plugin-theme-inject.js). - Fix:
- Run automated contrast checks (axe) on every page in both light and dark and for every built-in theme preset.
- Add a contrast guard to
ThemeCustomizerthat warns or clamps when a custom theme drops text below 4.5:1, or UI borders and focus rings below 3:1. - Text overlaid on video (stream names, timestamps, detection labels) must have a solid or scrim background so it stays ≥4.5:1 regardless of frame content.
P2-9 · Target size (SC 2.5.8 Target Size Minimum, AA, 24×24 CSS px)
scripts/audit-touch-targets.mjschecks 44px at 375px width, which is stricter than AA. Runnpm run auditand fix every target under 24×24 as a hard requirement, then work toward 44. Likely offenders: tile overlay icons, timeline speed buttons,×close spans, tree expand toggles, and the language flag selector (LanguageSelector.jsx, 40×22 image).
P3-1 · Misc
UI.jsx:193: snapshot previewalt="Snapshot"is hard-coded English and generic. Uset()and include the stream name and time.- Remaining
<img>elements need meaningful or emptyalt:ZoneEditor.jsx:546,StreamCard.jsx:268,timeline/TimelineSegments.jsx:342,timeline/TimelineCursor.jsx:435,recordings/RecordingsGrid.jsx:233,investigation/InvestigationView.jsx:592,common/Image.jsx:80. Decorative thumbnails that sit next to a text label should getalt="". - Use landmarks consistently. Desktop and mobile
<nav>inHeader.jsxneed distinctaria-labels (for example "Primary", "Mobile").Footer.jsxis fine. LoadingIndicator.jsxis a full-screen overlay. It needsrole="status", anaria-livetext such as "Loading…", andaria-busyon the region being loaded.- Session or auth timeouts (
AuthGate.jsx,utils/auth-utils.js): if a session expires while the user is filling a form, warn first and allow extension (SC 2.2.1). Preserve unsaved form data on re-login if feasible (SC 3.3.7 Redundant Entry / 2.2.5). - WCAG 2.2 SC 3.3.8 Accessible Authentication: the login and TOTP fields must allow paste and password managers. Do not block paste on
TotpSetupModal.jsxorLoginView.jsxinputs. - Keyboard shortcuts (
tinykeysusage): single-character shortcuts must be remappable or disableable, or active only on focus (SC 2.1.4 Character Key Shortcuts). Document the shortcuts in a help dialog. - Fullscreen (
FullscreenManager.jsx): Escape must exit, focus must return to the trigger, and controls must stay reachable in fullscreen.
Tooling to add (do this first, so fixes are verifiable)
- ESLint +
eslint-plugin-jsx-a11yinweb/, with therecommendedruleset. Start in warn mode, record the baseline count in this issue, then switch rules to error as each finding category is closed. Add it to CI. - axe-core in the existing Selenium e2e suite (
web/tests/e2e/,npm run test:e2e):- add
axe-core(or@axe-core/webdriverjs); - in each page object (
tests/e2e/pages/*.js), run axe after load and after opening each major modal; - fail on
serious/criticalviolations, taggedwcag2a, wcag2aa, wcag21aa, wcag22aa; - run it in light and dark themes.
- add
- Keyboard-only e2e smoke test. Tab through each page and assert:
- the skip link appears first;
- focus is always visible (non-zero outline or box-shadow on
document.activeElement); - modals trap focus and restore it;
- every interactive element is reachable.
- Extend
scripts/audit-touch-targets.mjswith a 24px AA threshold that fails the build. Keep 44px as a warning. - Optional: Lighthouse accessibility score ≥ 95 per page in CI.
Suggested work order for a coding agent
- Tooling (lint + axe) → record baseline violation counts in a comment on this issue.
- P1-1 zoom → P2-1 global focus-visible style → P2-2 skip link. These are small, global and high-impact.
- P1-4 Header and P2-3 headings/titles (shared chrome, affects every page).
- P1-5 Toast live regions.
- P1-2 modal migration onto
ModalDialog(largest change; one PR per area: streams, users, system/settings, recordings). - P1-3 and P2-4 non-semantic click targets and tile icon buttons.
- P2-6 forms, P2-7 widgets (tabs/menus/tree/plan/timeline), P2-8 contrast, P2-9 target size.
- P3 items.
Keep each PR scoped to one finding ID and reference it (for example "Fixes WCAG P1-5 from #"). Before submitting, build all targets and run cd web && npx vite build (the JSX compile check) and npm test.
Acceptance criteria
- No
serious/criticalaxe violations (WCAG 2.2 A/AA tags) on any page, in light and dark themes, including with each major modal open. -
eslint-plugin-jsx-a11yrecommended rules pass with zero errors in CI. - Every page usable keyboard-only: skip link, visible focus, logical order, no traps, every action reachable (including double-click-only and drag-only actions).
- All modals use the shared
ModalDialogprimitive. - Toasts and status changes announced through live regions; error toasts not auto-dismissed in under 10 s.
- Page zoom to 200% and reflow at 320 CSS px width with no loss of content or function (Live, Streams, Recordings, Timeline, Settings).
- Every interactive target ≥ 24×24 CSS px (or meets the spacing exception).
- Each page has a unique localized
<title>and a single<h1>. - Manual screen-reader pass (NVDA + Firefox on Windows, VoiceOver + Safari on macOS/iOS) on Login → Live → Streams (add camera) → Recordings (play/download) → Settings (save), with notes posted to this issue.
- Short
docs/ACCESSIBILITY.md: conformance target, known exceptions (for example video-only CCTV captions), keyboard shortcuts, how to run the a11y checks.
Notes / caveats on this audit
- This is a static source audit based on grep and file reading, without rendering in a browser. Line numbers are approximate (±a few lines) as of
e4fde7a5. Items marked "verify" need confirmation in a running browser. The axe and keyboard passes in the tooling section will surface issues static analysis can't see (computed contrast, focus order, live-region timing). - Follow the i18n rule: every new
aria-label, skip-link text, and so on must go throught()and be added to all locale files.
🤖 Generated with Claude Code
Activity
Improve web UI a11y foundations and add jsx-a11y lint tooling (opense…