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
parent 224681f1e0
commit 46747607ea
8 changed files with 557 additions and 176 deletions

View File

@@ -11,12 +11,13 @@
v-model="searchQuery"
type="text"
placeholder="Search installed apps..."
aria-label="Search installed apps"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<!-- Empty State - This should never show since we always show dummy apps -->
<div v-if="false" class="text-center py-16 pb-6">
<!-- Empty State -->
<div v-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
<div class="glass-card p-12 max-w-md mx-auto">
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
@@ -53,9 +54,10 @@
<button
@click.stop="showUninstallModal(id as string, pkg)"
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
:aria-label="`Uninstall ${pkg.manifest?.title || id}`"
title="Uninstall"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
@@ -107,6 +109,7 @@
<svg
v-if="loadingActions[id as string]"
class="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -125,6 +128,7 @@
<svg
v-if="loadingActions[id as string]"
class="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -149,16 +153,19 @@
<div
ref="uninstallModalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-labelledby="uninstall-dialog-title"
class="glass-card p-6 max-w-md w-full relative z-10"
>
<div class="flex items-start gap-4 mb-4">
<div class="p-3 bg-red-500/20 rounded-lg">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
<p class="text-white/70">
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
This will remove the app and stop its container.
@@ -183,6 +190,16 @@
</div>
</div>
</Transition>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" aria-label="Dismiss error" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
</div>
</template>
@@ -191,7 +208,7 @@ import { computed, ref, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState } from '../types/api'
import { PackageState, type PackageDataEntry } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
@@ -203,6 +220,16 @@ const searchQuery = ref('')
// Track loading states for each app action
const loadingActions = ref<Record<string, boolean>>({})
// Action error toast
const actionError = ref('')
let errorTimer: ReturnType<typeof setTimeout> | undefined
function showActionError(msg: string) {
actionError.value = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
// Use real packages from store - no more dummy apps
const packages = computed(() => {
const realPackages = store.packages
@@ -246,13 +273,13 @@ useModalKeyboard(
{ restoreFocusRef: uninstallRestoreFocusRef }
)
function canLaunch(pkg: any): boolean {
function canLaunch(pkg: PackageDataEntry): boolean {
// For dummy apps, allow launch if running (they have interface addresses)
// For real apps, check for UI interface
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
// Allow launch when running or starting (so buttons show even while backend reports "starting")
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
return hasUI && canLaunchState
return !!hasUI && canLaunchState
}
function launchApp(id: string) {
@@ -341,7 +368,8 @@ async function startApp(id: string) {
actionTimers.delete(id)
}, 5000))
} catch (err) {
console.error('Failed to start app:', err)
if (import.meta.env.DEV) console.error('Failed to start app:', err)
showActionError(`Failed to start app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
@@ -356,7 +384,8 @@ async function stopApp(id: string) {
actionTimers.delete(id)
}, 5000))
} catch (err) {
console.error('Failed to stop app:', err)
if (import.meta.env.DEV) console.error('Failed to stop app:', err)
showActionError(`Failed to stop app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
@@ -373,11 +402,11 @@ async function _restartApp(_id: string) {
try {
await store.restartPackage(_id)
} catch (err) {
console.error('Failed to restart app:', err)
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
}
}
function showUninstallModal(id: string, pkg: any) {
function showUninstallModal(id: string, pkg: PackageDataEntry) {
uninstallModal.value = {
show: true,
appId: id,
@@ -392,8 +421,8 @@ async function confirmUninstall() {
try {
await store.uninstallPackage(appId)
} catch (err) {
console.error('Failed to uninstall app:', err)
alert('Failed to uninstall app')
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}