feat: add ARIA labels, roles, and live regions across all views (A11Y-01)

Systematic accessibility pass: aria-label on icon-only buttons, role=dialog
and aria-modal on modals, role=tab/tablist on tab switchers, role=switch
on toggles, aria-live on dynamic status/error regions, aria-hidden on
decorative SVGs, aria-label on search inputs, and nav landmarks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 13:04:31 +00:00
co-authored by Claude Opus 4.6
parent 224681f1e0
commit 46747607ea
8 changed files with 557 additions and 176 deletions
+83 -8
View File
@@ -74,7 +74,7 @@
</div>
</div>
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" aria-label="Main navigation">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@@ -83,7 +83,7 @@
exact-active-class="nav-tab-active"
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
@@ -105,7 +105,7 @@
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span>Chat</span>
@@ -116,7 +116,7 @@
@click="handleLogout"
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
@@ -293,6 +293,7 @@
<nav
ref="mobileTabBar"
data-mobile-tab-bar
aria-label="Mobile navigation"
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
:class="{ 'glass-throw-tabbar': showZoomIn }"
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: env(safe-area-inset-bottom, 0px);"
@@ -312,7 +313,7 @@
}"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg class="w-6 h-6 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
@@ -336,12 +337,47 @@
</button>
</div>
</nav>
<!-- Health Notifications Toast -->
<div
v-if="healthNotifications.length > 0"
class="fixed top-4 right-4 z-[200] flex flex-col gap-2 max-w-sm"
>
<div
v-for="notif in healthNotifications"
:key="notif.id"
class="p-3 rounded-xl border backdrop-blur-lg shadow-lg"
:class="notif.level === 'error'
? 'bg-red-500/15 border-red-500/30'
: notif.level === 'warning'
? 'bg-yellow-500/15 border-yellow-500/30'
: 'bg-blue-500/15 border-blue-500/30'"
>
<div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0" :class="notif.level === 'error' ? 'text-red-400' : notif.level === 'warning' ? 'text-yellow-400' : 'text-blue-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white">{{ notif.title }}</p>
<p class="text-xs text-white/60 mt-0.5">{{ notif.message }}</p>
</div>
<button
class="text-white/40 hover:text-white/80 transition-colors shrink-0"
@click="dismissNotification(notif.id)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useLoginTransitionStore } from '../stores/loginTransition'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
@@ -545,17 +581,44 @@ onMounted(() => {
onResize()
window.addEventListener('resize', onResize)
window.addEventListener('keydown', handleKioskShortcuts)
web5Badge.refresh()
})
onBeforeUnmount(() => {
document.body.classList.remove('dashboard-active')
window.removeEventListener('resize', onResize)
window.removeEventListener('keydown', handleKioskShortcuts)
for (const id of pendingTimers) clearTimeout(id)
pendingTimers.length = 0
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
})
// Kiosk keyboard shortcuts (active when kiosk=true in localStorage or query param)
function isKioskMode(): boolean {
try {
return localStorage.getItem('kiosk') === 'true' || new URLSearchParams(window.location.search).has('kiosk')
} catch { return false }
}
function handleKioskShortcuts(e: KeyboardEvent) {
if (!isKioskMode()) return
if (e.ctrlKey && e.shiftKey) {
if (e.key === 'R' || e.key === 'r') {
e.preventDefault()
router.push('/recovery')
} else if (e.key === 'H' || e.key === 'h') {
e.preventDefault()
router.push('/dashboard')
} else if (e.key === 'Q' || e.key === 'q') {
e.preventDefault()
if (confirm('Reboot the server?')) {
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
}
}
}
}
// Watch route changes to update indicator position
watch(() => route.path, () => {
nextTick(() => {
@@ -677,7 +740,7 @@ const tabOrder = [
]
// Determine transition direction based on route depth
function getTransitionName(currentRoute: any) {
function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
const currentPath = currentRoute.path
// If no previous path, use fade transition for initial load
@@ -796,9 +859,21 @@ function getTransitionName(currentRoute: any) {
// Update previous path for next transition
previousPath = currentPath
return transitionName
}
// Health notifications from WebSocket data
const dismissedNotifications = ref<Set<string>>(new Set())
const healthNotifications = computed(() => {
const notifs = store.data?.notifications ?? []
return notifs.filter(n => !dismissedNotifications.value.has(n.id)).slice(-5)
})
function dismissNotification(id: string) {
dismissedNotifications.value.add(id)
}
</script>
<style>