Skip to content

Audit & fix WCAG 2.2 AA accessibility across the web UI #647

Closed

Description

@matteius

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/*.html serves (index, streams, recordings, timeline, investigation, settings, users, system, login, hls) and all components in web/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.jsx is a correct reference dialog. It has role="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" with aria-valuetext and keyboard shortcuts (timeline/TimelineCursor.jsx:407).
  • A touch-target audit exists: scripts/audit-touch-targets.mjs, run with npm 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 the Field helpers in events/*Editor.jsx) or with htmlFor.

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:5 and web/hls.html:5 contain maximum-scale=1.0, user-scalable=no.
  • Fix: remove maximum-scale and user-scalable. If pinch-to-zoom on video tiles conflicts with page zoom, handle it inside the tile (MobileTileGestures.jsx, useVideoZoom.js already 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.jsx so it can host large or scrolling content. Add a generic <Modal> export with a size prop (md|lg|xl|full) next to ConfirmDialog. Then migrate every modal above to it. Delete the ad-hoc backdrops and stopPropagation wrappers.
  • While migrating, make sure that:
    • background content is inert while a modal is open (inert on #app/siblings, or rely on aria-modal plus the trap);
    • stacked modals (for example ConfirmDialog opened from StreamConfigModal) each trap and restore focus correctly;
    • the destructive confirm dialogs (StreamDeleteModal, BatchDeleteModal, DeleteUserModal, ClearLogsModal) put initial focus on Cancel, as ConfirmDialog already does.

P1-3 · Click handlers on non-interactive elements (SC 2.1.1 Keyboard, 4.1.2)

  • StreamsView.jsx:1795 and StreamsView.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"/treeitem with aria-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 on onDblClick, 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-interactions and jsx-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) calls renderNavItem with no href, which renders <a> without href and without tabIndex/role. Anchors without href are not focusable. Render actions as <button type="button"> and keep <a> for navigation.
  • The mobile menu toggle (~L356) has no aria-expanded={mobileMenuOpen} or aria-controls. Its accessible name doesn't say whether the menu is open or closed. It also uses focus:outline-none with 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-disabled together with pointer-events-none, but with no href it 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 an aria-label that 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 no role="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.jsx showToast defaults). 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, and role="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.

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-none have no matching focus: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, and css/investigation.css:639 set outline: none. Check each one for a replacement :focus-visible style.
  • Fix: add a global rule in css/base.css:
    :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
      outline: 2px solid hsl(var(--ring, var(--primary)));
      outline-offset: 2px;
    }
    Then remove the bare focus:outline-none utilities, or pair each with focus-visible:ring-2 focus-visible:ring-[hsl(var(--primary))]. The ring needs ≥3:1 contrast against both light and dark --card/--background and 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-top set 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-content exists in every web/*.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 from Header.jsx. Add tabindex="-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). Only AuthGate, EventRoutingView, FleetView, InvestigationView, LoginView and TimelinePage render 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 that h2/h3 levels nest without skipping.
  • document.title is set only on Live (pages/index-page.jsx:86), Login and AuthGate. Check that each web/*.html has 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 in WebRTCVideoCell.jsx. Many icon buttons rely on title= alone for their name. title is 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 via t()), and mark its SVG aria-hidden="true" focusable="false".
    • Add aria-pressed to 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.
  • retry-button in 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 as aria-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 179 htmlFor/for. Many of the rest are wrapped in <label> (fine), but audit StreamConfigModal.jsx (1744 lines), StreamsView.jsx, settings/*Tab.jsx, ZoneEditor.jsx and SetupWizard.jsx for unlabelled or placeholder-only fields. StreamsView.jsx:1944,1956,2030 are likely candidates.
  • Only 3 uses of aria-invalid and 3 of aria-describedby in the whole app. Validation errors must be:
    • tied to their field (aria-invalid="true" plus aria-describedby pointing 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.
  • Required fields: use required/aria-required and show a visible marker that is explained once at the top of the form.
  • Help text (Field help prop in events/*Editor.jsx) is inside the <label>, so it becomes part of the accessible name. Move help into a sibling element referenced by aria-describedby.
  • Add autocomplete tokens 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 8 role="tablist" (Settings, Users, System, Investigation, …). Check roving tabIndex, Left/Right/Home/End keys, aria-controls↔id, and aria-selected.
  • Menus: 3 role="menu" and 4 role="menuitem", including common/LanguageSelector.jsx. Check that arrow keys move between items, Escape closes and returns focus, and the trigger has aria-haspopup and aria-expanded. aria-expanded appears 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" (besides TimelineCursor) and role="progressbar" need min, max, now and text values.
  • role="application" (find it with grep -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:330 has a hard-coded English aria-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-foreground on bg-muted, hsl(var(--primary)/0.8) hover states, opacity-60 disabled 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 ThemeCustomizer that 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.mjs checks 44px at 375px width, which is stricter than AA. Run npm run audit and 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 preview alt="Snapshot" is hard-coded English and generic. Use t() and include the stream name and time.
  • Remaining <img> elements need meaningful or empty alt: 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 get alt="".
  • Use landmarks consistently. Desktop and mobile <nav> in Header.jsx need distinct aria-labels (for example "Primary", "Mobile"). Footer.jsx is fine.
  • LoadingIndicator.jsx is a full-screen overlay. It needs role="status", an aria-live text such as "Loading…", and aria-busy on 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.jsx or LoginView.jsx inputs.
  • Keyboard shortcuts (tinykeys usage): 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)

  1. ESLint + eslint-plugin-jsx-a11y in web/, with the recommended ruleset. 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.
  2. 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/critical violations, tagged wcag2a, wcag2aa, wcag21aa, wcag22aa;
    • run it in light and dark themes.
  3. 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.
  4. Extend scripts/audit-touch-targets.mjs with a 24px AA threshold that fails the build. Keep 44px as a warning.
  5. Optional: Lighthouse accessibility score ≥ 95 per page in CI.

Suggested work order for a coding agent

  1. Tooling (lint + axe) → record baseline violation counts in a comment on this issue.
  2. P1-1 zoom → P2-1 global focus-visible style → P2-2 skip link. These are small, global and high-impact.
  3. P1-4 Header and P2-3 headings/titles (shared chrome, affects every page).
  4. P1-5 Toast live regions.
  5. P1-2 modal migration onto ModalDialog (largest change; one PR per area: streams, users, system/settings, recordings).
  6. P1-3 and P2-4 non-semantic click targets and tile icon buttons.
  7. P2-6 forms, P2-7 widgets (tabs/menus/tree/plan/timeline), P2-8 contrast, P2-9 target size.
  8. 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/critical axe violations (WCAG 2.2 A/AA tags) on any page, in light and dark themes, including with each major modal open.
  • eslint-plugin-jsx-a11y recommended 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 ModalDialog primitive.
  • 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 through t() and be added to all locale files.

🤖 Generated with Claude Code

Activity

  1. added
    enhancementNew feature or request
    javascriptPull requests that update javascript code
    agent-candidateMaintainer-approved issue for a managed agent PR; merge stays manual
    and removed
    agent-candidateMaintainer-approved issue for a managed agent PR; merge stays manual
    on Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-candidateMaintainer-approved issue for a managed agent PR; merge stays manualenhancementNew feature or requestjavascriptPull requests that update javascript code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions