Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/static-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ permissions:
contents: read

jobs:
web-jsx-a11y:
name: Web JSX accessibility lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
run: npm ci --prefix web
- name: Report JSX accessibility findings
run: |
npm run lint --prefix web
echo 'The existing WCAG audit findings are warnings. Promote each rule to an error as its finding is fixed.' >> "$GITHUB_STEP_SUMMARY"

clang-tidy:
name: clang-tidy
runs-on: ubuntu-latest
Expand Down
45 changes: 44 additions & 1 deletion web/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@

/* Base Styles */
@layer base {
html {
/* Keep focused elements clear of the fixed page header so focus is not
obscured (WCAG 2.4.11). Roughly the header height plus its margin. */
scroll-padding-top: 5rem;
}

body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow-y: scroll;
Expand All @@ -147,11 +153,13 @@
focus-visible treatment. Deliberately use an outline so it remains visible
even when a component clips box shadows. */
:where(
button,
a[href],
button,
input,
select,
textarea,
summary,
[tabindex],
[role="button"]
):focus-visible {
outline: 2px solid hsl(var(--ring)) !important;
Expand Down Expand Up @@ -180,6 +188,41 @@
}
}

/* "Skip to main content" link (WCAG 2.4.1): visually hidden until focused. */
.skip-link {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

.skip-link:focus-visible {
position: fixed;
top: 1rem;
left: 1rem;
width: auto;
height: auto;
padding: 0.5rem 1rem;
margin: 0;
overflow: visible;
clip: auto;
clip-path: none;
white-space: normal;
z-index: 9999;
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: var(--radius);
text-decoration: none;
outline: 2px solid hsl(var(--primary-foreground));
outline-offset: 2px;
}

/* Component Styles */
@layer components {
/* Status Message */
Expand Down
48 changes: 48 additions & 0 deletions web/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* ESLint configuration for the LightNVR web UI.
*
* Adds eslint-plugin-jsx-a11y (recommended ruleset) as part of the WCAG 2.2 AA
* audit in https://github.com/opensensor/lightNVR/issues/647. All rules start
* in warn mode so CI reports violations without failing the build; switch rules
* to error as each finding category in the issue is closed.
*/
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactHooks from 'eslint-plugin-react-hooks';

const recommendedRules = jsxA11y.flatConfigs.recommended.rules;

const a11yWarnRules = Object.fromEntries(
Object.keys(recommendedRules).map((name) => [name, 'warn']),
);

export default [
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
{
files: ['js/**/*.js', 'js/**/*.jsx'],
linterOptions: {
// Targeted disable comments (e.g. react-hooks) belong to configs outside
// this a11y lint; don't flag them as unused here.
reportUnusedDisableDirectives: 'off',
},
plugins: { 'jsx-a11y': jsxA11y, 'react-hooks': reactHooks },
languageOptions: {
...jsxA11y.flatConfigs.recommended.languageOptions,
sourceType: 'module',
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
rules: {
...a11yWarnRules,
// The plugin is registered only so pre-existing targeted
// eslint-disable-next-line comments referencing react-hooks rules
// resolve; its rules stay off while this config tracks the a11y audit.
'react-hooks/exhaustive-deps': 'off',
'react-hooks/rules-of-hooks': 'off',
},
},
];
4 changes: 2 additions & 2 deletions web/hls.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>HLS View - LightNVR</title>
Expand All @@ -21,7 +21,7 @@
<!-- Header content will be loaded by JavaScript -->
</div>

<main id="main-content" class="flex-grow">
<main id="main-content" class="flex-grow" tabindex="-1">
<div id="app-root">
<!-- HLSView component will be rendered here -->
</div>
Expand Down
4 changes: 2 additions & 2 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Live View - LightNVR</title>
Expand All @@ -21,7 +21,7 @@
<!-- Header content will be loaded by JavaScript -->
</div>

<main id="main-content" class="flex-grow">
<main id="main-content" class="flex-grow" tabindex="-1">
</main>

<div id="footer-container" class="mt-auto">
Expand Down
2 changes: 1 addition & 1 deletion web/investigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<body class="flex flex-col min-h-screen bg-background text-foreground">
<div class="container mx-auto px-4 flex flex-col min-h-screen">
<div id="header-container" data-active-nav="nav-recordings"></div>
<main id="main-content" class="flex-grow"></main>
<main id="main-content" class="flex-grow" tabindex="-1"></main>
<div id="footer-container" class="mt-auto"></div>
</div>
<div id="status-message-container"></div>
Expand Down
5 changes: 3 additions & 2 deletions web/js/components/preact/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,13 @@ export function Header({ version = VERSION }) {

return (
<>
<a href="#main-content" className="skip-link">{t('a11y.skipToMainContent')}</a>
<header className="app-header py-2 shadow-md mb-4 w-full" style={{ position: 'relative', zIndex: 20, backgroundColor: 'hsl(var(--card))', color: 'hsl(var(--card-foreground))' }}>
<div className="container mx-auto px-4 flex justify-between items-center">
<a
href={getLiveViewHref()}
id="nav-logo"
className="logo flex items-center no-underline text-inherit rounded hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:ring-offset-2"
className="logo flex items-center no-underline text-inherit rounded hover:opacity-90 focus:ring-2 focus:ring-[hsl(var(--primary))] focus:ring-offset-2"
style={{ color: 'inherit' }}
aria-label={t('nav.live')}
onClick={(e) => {
Expand Down Expand Up @@ -354,7 +355,7 @@ export function Header({ version = VERSION }) {

{/* Mobile Menu Button */}
<button
className="xl:hidden p-2 focus:outline-none"
className="xl:hidden p-2"
style={{color: 'hsl(var(--card-foreground))'}}
onClick={toggleMobileMenu}
aria-label={t('nav.toggleMenu')}
Expand Down
32 changes: 16 additions & 16 deletions web/js/components/preact/LiveView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
) : (
<a
href={buildLiveViewHref('/index.html', window.location.search)}
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground focus:outline-none"
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground"
>
{t('live.autoShort')}
</a>
Expand All @@ -672,7 +672,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
) : (
<a
href={buildLiveViewHref('/index.html', window.location.search, 'webrtc')}
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground focus:outline-none"
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground"
>
{t('live.webrtcShort')}
</a>
Expand All @@ -686,7 +686,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
) : (
<a
href={buildLiveViewHref('/hls.html', window.location.search)}
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground focus:outline-none"
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground"
>
{t('live.hlsShort')}
</a>
Expand All @@ -700,7 +700,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
) : (
<a
href={buildLiveViewHref('/hls.html', window.location.search, 'mse')}
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground focus:outline-none"
className="px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline text-muted-foreground hover:bg-background hover:text-foreground"
>
{t('live.mseShort')}
</a>
Expand All @@ -714,7 +714,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
<button
type="button"
id="hls-favorites-filter"
className={`px-3 py-2 border rounded-md shadow-sm text-sm whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary ${favoritesOnly ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-foreground border-border'}`}
className={`px-3 py-2 border rounded-md shadow-sm text-sm whitespace-nowrap focus:ring-2 focus:ring-primary ${favoritesOnly ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-foreground border-border'}`}
aria-pressed={favoritesOnly}
title={t('live.favoritesOnly')}
onClick={() => { setFavoritesOnly((current) => !current); setCurrentPage(0); }}
Expand All @@ -727,7 +727,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
<label htmlFor="hls-collection-filter" className="text-sm whitespace-nowrap">{t('collections.filter')}:</label>
<select
id="hls-collection-filter"
className="px-3 py-2 border border-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground"
className="px-3 py-2 border border-border rounded-md shadow-sm focus:ring-2 focus:ring-primary bg-background text-foreground"
value={collectionFilter}
disabled={isCollectionLoading}
onChange={(event) => { setCollectionFilter(event.currentTarget.value); setCurrentPage(0); }}
Expand All @@ -744,7 +744,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
<label htmlFor="tag-filter" className="text-sm whitespace-nowrap">{t('recordings.tags')}:</label>
<select
id="tag-filter"
className="px-3 py-2 border border-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground"
className="px-3 py-2 border border-border rounded-md shadow-sm focus:ring-2 focus:ring-primary bg-background text-foreground"
value={tagFilter}
onChange={(e) => { setTagFilter(e.target.value); setCurrentPage(0); }}
>
Expand All @@ -770,7 +770,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
<label htmlFor="stream-selector" className="text-sm whitespace-nowrap">{t('nav.streams')}:</label>
<select
id="stream-selector"
className="px-3 py-2 border border-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground"
className="px-3 py-2 border border-border rounded-md shadow-sm focus:ring-2 focus:ring-primary bg-background text-foreground"
value={selectedStream}
onChange={(e) => setSelectedStream(e.target.value)}
>
Expand All @@ -782,7 +782,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
)}

<button
className={`p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary ${showLabels ? 'bg-secondary hover:bg-secondary/80 text-secondary-foreground' : 'bg-primary/20 hover:bg-primary/30 text-primary'}`}
className={`p-2 rounded-full focus:ring-2 focus:ring-primary ${showLabels ? 'bg-secondary hover:bg-secondary/80 text-secondary-foreground' : 'bg-primary/20 hover:bg-primary/30 text-primary'}`}
onClick={() => setShowLabels(v => !v)}
title={showLabels ? t('live.hideStreamLabels') : t('live.showStreamLabels')}
>
Expand All @@ -795,7 +795,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
</button>

<button
className={`p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary ${showControls ? 'bg-secondary hover:bg-secondary/80 text-secondary-foreground' : 'bg-primary/20 hover:bg-primary/30 text-primary'}`}
className={`p-2 rounded-full focus:ring-2 focus:ring-primary ${showControls ? 'bg-secondary hover:bg-secondary/80 text-secondary-foreground' : 'bg-primary/20 hover:bg-primary/30 text-primary'}`}
onClick={() => setShowControls(v => !v)}
title={showControls ? t('live.hideStreamControls') : t('live.showStreamControls')}
>
Expand All @@ -815,7 +815,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
</button>

<button
className={`p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary ${showDetections ? 'bg-secondary hover:bg-secondary/80 text-secondary-foreground' : 'bg-primary/20 hover:bg-primary/30 text-primary'}`}
className={`p-2 rounded-full focus:ring-2 focus:ring-primary ${showDetections ? 'bg-secondary hover:bg-secondary/80 text-secondary-foreground' : 'bg-primary/20 hover:bg-primary/30 text-primary'}`}
onClick={() => setShowDetections(v => !v)}
title={showDetections ? t('live.hideAllDetectionOverlays') : t('live.showAllDetectionOverlays')}
>
Expand Down Expand Up @@ -852,7 +852,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT

{orderedStreams.length > 1 && (
<button
className={`p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary ${reorderMode ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-secondary hover:bg-secondary/80 text-secondary-foreground'}`}
className={`p-2 rounded-full focus:ring-2 focus:ring-primary ${reorderMode ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-secondary hover:bg-secondary/80 text-secondary-foreground'}`}
onClick={toggleReorderMode}
title={reorderMode ? t('live.exitReorderMode') : t('live.dragToReorderCameras')}
>
Expand All @@ -868,7 +868,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT

{reorderMode && (
<button
className="p-2 rounded-full bg-secondary hover:bg-secondary/80 text-secondary-foreground focus:outline-none focus:ring-2 focus:ring-primary"
className="p-2 rounded-full bg-secondary hover:bg-secondary/80 text-secondary-foreground focus:ring-2 focus:ring-primary"
onClick={resetOrder}
title={t('live.resetCameraOrder')}
>
Expand All @@ -883,7 +883,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT

<button
id="fullscreen-btn"
className="p-2 rounded-full bg-secondary hover:bg-secondary/80 text-secondary-foreground focus:outline-none"
className="p-2 rounded-full bg-secondary hover:bg-secondary/80 text-secondary-foreground"
onClick={() => toggleFullscreen()}
title={t('timeline.fullscreen')}
>
Expand Down Expand Up @@ -1074,7 +1074,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
{!isSingleStream && orderedStreams.length > maxStreams ? (
<div className="pagination-controls flex justify-center items-center space-x-4 mt-4">
<button
className="btn-primary focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed"
className="btn-primary focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
console.log('Changing to previous page');
setCurrentPage(Math.max(0, currentPage - 1));
Expand All @@ -1089,7 +1089,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
</span>

<button
className="btn-primary focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed"
className="btn-primary focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
console.log('Changing to next page');
const totalPages = Math.ceil(orderedStreams.length / maxStreams);
Expand Down
Loading
Loading