feat(a11y): accessibility audit, high contrast, i18n foundation, skip nav (M17.1-M17.8)

- Keyboard navigation: useFocusTrap() for modals, useRovingTabindex() for grids
- ARIA audit: composables for focus trap and roving tabindex patterns
- High contrast mode: @media (prefers-contrast: more) + .high-contrast class
- i18n foundation: en/es/fr locale files, useI18n() composable with auto-detect
- RTL layout support: dir attribute toggling based on locale
- Dyslexia-friendly font: .font-dyslexia CSS class with OpenDyslexic support
- Skip navigation link: .skip-nav CSS with focus-visible positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:57:37 +00:00
co-authored by Claude Opus 4.6
parent 85936d3005
commit 725f629f69
7 changed files with 506 additions and 0 deletions
@@ -0,0 +1,53 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
function getFocusableElements(): HTMLElement[] {
const container = containerRef.value
if (!container) return []
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
// Bubble up for close handlers
return
}
if (e.key !== 'Tab') return
const focusable = getFocusableElements()
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
// Auto-focus first element
const focusable = getFocusableElements()
if (focusable.length > 0) {
focusable[0].focus()
}
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
})
}
@@ -0,0 +1,69 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
export function useRovingTabindex(containerRef: Ref<HTMLElement | null>, selector = '[role="listitem"], [data-roving]') {
const currentIndex = ref(0)
function getItems(): HTMLElement[] {
const container = containerRef.value
if (!container) return []
return Array.from(container.querySelectorAll<HTMLElement>(selector))
}
function updateTabindex() {
const items = getItems()
items.forEach((item, i) => {
item.setAttribute('tabindex', i === currentIndex.value ? '0' : '-1')
})
}
function onKeyDown(e: KeyboardEvent) {
const items = getItems()
if (items.length === 0) return
let handled = false
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
currentIndex.value = (currentIndex.value + 1) % items.length
handled = true
break
case 'ArrowUp':
case 'ArrowLeft':
currentIndex.value = (currentIndex.value - 1 + items.length) % items.length
handled = true
break
case 'Home':
currentIndex.value = 0
handled = true
break
case 'End':
currentIndex.value = items.length - 1
handled = true
break
}
if (handled) {
e.preventDefault()
updateTabindex()
items[currentIndex.value]?.focus()
}
}
onMounted(() => {
const container = containerRef.value
if (container) {
container.addEventListener('keydown', onKeyDown)
updateTabindex()
}
})
onUnmounted(() => {
const container = containerRef.value
if (container) {
container.removeEventListener('keydown', onKeyDown)
}
})
return { currentIndex, updateTabindex }
}
+89
View File
@@ -0,0 +1,89 @@
{
"app": {
"name": "AIUI",
"tagline": "AI chat with rich content surfaces"
},
"chat": {
"newChat": "New Chat",
"sendMessage": "Send message",
"typeMessage": "Type a message...",
"stopGenerating": "Stop generating",
"regenerate": "Regenerate",
"edit": "Edit",
"delete": "Delete",
"copy": "Copy",
"reply": "Reply",
"branch": "Branch from here",
"search": "Search messages...",
"noMessages": "No messages yet",
"thinking": "Thinking..."
},
"content": {
"films": "Films",
"music": "Music",
"books": "Books",
"tv": "TV",
"images": "Images",
"places": "Places",
"podcasts": "Podcasts",
"news": "News",
"web": "Web",
"brief": "Brief",
"code": "Code",
"design": "Design",
"nostr": "Nostr",
"favorites": "Favorites",
"discover": "Discover"
},
"discover": {
"forYou": "For You",
"recent": "Recent",
"trending": "Trending",
"collections": "Collections",
"tags": "Tags",
"playlists": "Playlists",
"noFavorites": "Add favorites to get personalized suggestions",
"noRecent": "No recently viewed items",
"noTrending": "No trending items yet",
"noCollections": "No collections yet",
"noTags": "No tags yet"
},
"settings": {
"title": "Settings",
"appearance": "Appearance",
"content": "Content",
"shortcuts": "Shortcuts",
"notifications": "Notifications",
"storage": "Storage",
"chat": "Chat",
"accentColor": "Accent Colour",
"glassIntensity": "Glass Intensity",
"fontSize": "Font Size",
"exportData": "Export All Data",
"wipeData": "Wipe All Data",
"wipeConfirm": "Are you sure?",
"cancel": "Cancel"
},
"nostr": {
"feed": "Feed",
"messages": "Messages",
"relays": "Relays",
"profile": "Profile",
"lists": "Lists",
"articles": "Articles",
"publish": "Publish",
"signIn": "Sign in with Nostr"
},
"common": {
"loading": "Loading...",
"error": "Error",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"close": "Close",
"search": "Search",
"back": "Back",
"next": "Next",
"previous": "Previous"
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"app": {
"name": "AIUI",
"tagline": "Chat IA con superficies de contenido enriquecidas"
},
"chat": {
"newChat": "Nuevo Chat",
"sendMessage": "Enviar mensaje",
"typeMessage": "Escribe un mensaje...",
"stopGenerating": "Detener generación",
"regenerate": "Regenerar",
"edit": "Editar",
"delete": "Eliminar",
"copy": "Copiar",
"reply": "Responder",
"branch": "Bifurcar desde aquí",
"search": "Buscar mensajes...",
"noMessages": "Sin mensajes aún",
"thinking": "Pensando..."
},
"content": {
"films": "Películas",
"music": "Música",
"books": "Libros",
"tv": "TV",
"images": "Imágenes",
"places": "Lugares",
"podcasts": "Podcasts",
"news": "Noticias",
"web": "Web",
"brief": "Resumen",
"code": "Código",
"design": "Diseño",
"nostr": "Nostr",
"favorites": "Favoritos",
"discover": "Descubrir"
},
"discover": {
"forYou": "Para Ti",
"recent": "Recientes",
"trending": "Tendencias",
"collections": "Colecciones",
"tags": "Etiquetas",
"playlists": "Listas",
"noFavorites": "Añade favoritos para sugerencias personalizadas",
"noRecent": "Sin elementos vistos recientemente",
"noTrending": "Sin tendencias aún",
"noCollections": "Sin colecciones aún",
"noTags": "Sin etiquetas aún"
},
"settings": {
"title": "Ajustes",
"appearance": "Apariencia",
"content": "Contenido",
"shortcuts": "Atajos",
"notifications": "Notificaciones",
"storage": "Almacenamiento",
"chat": "Chat",
"accentColor": "Color de acento",
"glassIntensity": "Intensidad del cristal",
"fontSize": "Tamaño de fuente",
"exportData": "Exportar todos los datos",
"wipeData": "Borrar todos los datos",
"wipeConfirm": "¿Estás seguro?",
"cancel": "Cancelar"
},
"nostr": {
"feed": "Feed",
"messages": "Mensajes",
"relays": "Relés",
"profile": "Perfil",
"lists": "Listas",
"articles": "Artículos",
"publish": "Publicar",
"signIn": "Iniciar sesión con Nostr"
},
"common": {
"loading": "Cargando...",
"error": "Error",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"close": "Cerrar",
"search": "Buscar",
"back": "Atrás",
"next": "Siguiente",
"previous": "Anterior"
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"app": {
"name": "AIUI",
"tagline": "Chat IA avec des surfaces de contenu enrichies"
},
"chat": {
"newChat": "Nouveau Chat",
"sendMessage": "Envoyer le message",
"typeMessage": "Tapez un message...",
"stopGenerating": "Arrêter la génération",
"regenerate": "Régénérer",
"edit": "Modifier",
"delete": "Supprimer",
"copy": "Copier",
"reply": "Répondre",
"branch": "Bifurquer à partir d'ici",
"search": "Rechercher des messages...",
"noMessages": "Pas encore de messages",
"thinking": "Réflexion..."
},
"content": {
"films": "Films",
"music": "Musique",
"books": "Livres",
"tv": "TV",
"images": "Images",
"places": "Lieux",
"podcasts": "Podcasts",
"news": "Actualités",
"web": "Web",
"brief": "Résumé",
"code": "Code",
"design": "Design",
"nostr": "Nostr",
"favorites": "Favoris",
"discover": "Découvrir"
},
"discover": {
"forYou": "Pour Vous",
"recent": "Récents",
"trending": "Tendances",
"collections": "Collections",
"tags": "Étiquettes",
"playlists": "Playlists",
"noFavorites": "Ajoutez des favoris pour des suggestions personnalisées",
"noRecent": "Aucun élément consulté récemment",
"noTrending": "Pas encore de tendances",
"noCollections": "Pas encore de collections",
"noTags": "Pas encore d'étiquettes"
},
"settings": {
"title": "Paramètres",
"appearance": "Apparence",
"content": "Contenu",
"shortcuts": "Raccourcis",
"notifications": "Notifications",
"storage": "Stockage",
"chat": "Chat",
"accentColor": "Couleur d'accent",
"glassIntensity": "Intensité du verre",
"fontSize": "Taille de police",
"exportData": "Exporter toutes les données",
"wipeData": "Effacer toutes les données",
"wipeConfirm": "Êtes-vous sûr ?",
"cancel": "Annuler"
},
"nostr": {
"feed": "Fil",
"messages": "Messages",
"relays": "Relais",
"profile": "Profil",
"lists": "Listes",
"articles": "Articles",
"publish": "Publier",
"signIn": "Se connecter avec Nostr"
},
"common": {
"loading": "Chargement...",
"error": "Erreur",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"close": "Fermer",
"search": "Rechercher",
"back": "Retour",
"next": "Suivant",
"previous": "Précédent"
}
}
+63
View File
@@ -0,0 +1,63 @@
import { ref, computed } from 'vue'
import en from './en.json'
import es from './es.json'
import fr from './fr.json'
type Messages = typeof en
const STORAGE_KEY = 'aiui-locale'
const locales: Record<string, Messages> = { en, es, fr }
const currentLocale = ref(loadLocale())
function loadLocale(): string {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved && locales[saved]) return saved
// Auto-detect from browser
const browserLang = navigator.language.split('-')[0]
if (locales[browserLang]) return browserLang
return 'en'
}
export function useI18n() {
const locale = computed({
get: () => currentLocale.value,
set: (v: string) => {
currentLocale.value = v
localStorage.setItem(STORAGE_KEY, v)
// Set dir attribute for RTL
document.documentElement.dir = isRtl(v) ? 'rtl' : 'ltr'
},
})
const messages = computed(() => locales[currentLocale.value] ?? en)
function t(path: string): string {
const keys = path.split('.')
let result: unknown = messages.value
for (const key of keys) {
if (result && typeof result === 'object') {
result = (result as Record<string, unknown>)[key]
} else {
return path
}
}
return typeof result === 'string' ? result : path
}
const availableLocales = Object.keys(locales)
return {
locale,
messages,
t,
availableLocales,
}
}
function isRtl(locale: string): boolean {
return ['ar', 'he', 'fa', 'ur'].includes(locale)
}
+54
View File
@@ -842,6 +842,60 @@ input:focus-visible {
}
}
/* ===== HIGH CONTRAST MODE ===== */
@media (prefers-contrast: more) {
.glass,
.glass-strong,
.glass-card {
border-color: rgba(255, 255, 255, 0.5);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.text-white\/90,
.text-white\/80,
.text-white\/70 {
color: white !important;
}
}
.high-contrast .glass,
.high-contrast .glass-strong,
.high-contrast .glass-card {
border-color: rgba(255, 255, 255, 0.5);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* ===== SKIP NAVIGATION ===== */
.skip-nav {
position: absolute;
top: -100%;
left: 0;
z-index: 9999;
padding: 8px 16px;
background: #F7931A;
color: #000;
font-weight: bold;
font-size: 14px;
text-decoration: none;
border-radius: 0 0 8px 0;
}
.skip-nav:focus {
top: 0;
}
/* ===== DYSLEXIA FONT ===== */
.font-dyslexia {
font-family: 'OpenDyslexic', sans-serif;
letter-spacing: 0.05em;
line-height: 1.6;
}
/* ===== REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {