diff --git a/packages/app/src/composables/useFocusTrap.ts b/packages/app/src/composables/useFocusTrap.ts new file mode 100644 index 00000000..5504bcb0 --- /dev/null +++ b/packages/app/src/composables/useFocusTrap.ts @@ -0,0 +1,53 @@ +import { onMounted, onUnmounted, type Ref } from 'vue' + +export function useFocusTrap(containerRef: Ref) { + function getFocusableElements(): HTMLElement[] { + const container = containerRef.value + if (!container) return [] + return Array.from( + container.querySelectorAll( + '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) + }) +} diff --git a/packages/app/src/composables/useRovingTabindex.ts b/packages/app/src/composables/useRovingTabindex.ts new file mode 100644 index 00000000..49d4577b --- /dev/null +++ b/packages/app/src/composables/useRovingTabindex.ts @@ -0,0 +1,69 @@ +import { ref, onMounted, onUnmounted, type Ref } from 'vue' + +export function useRovingTabindex(containerRef: Ref, selector = '[role="listitem"], [data-roving]') { + const currentIndex = ref(0) + + function getItems(): HTMLElement[] { + const container = containerRef.value + if (!container) return [] + return Array.from(container.querySelectorAll(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 } +} diff --git a/packages/app/src/i18n/en.json b/packages/app/src/i18n/en.json new file mode 100644 index 00000000..31a597e9 --- /dev/null +++ b/packages/app/src/i18n/en.json @@ -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" + } +} diff --git a/packages/app/src/i18n/es.json b/packages/app/src/i18n/es.json new file mode 100644 index 00000000..bb85d8f7 --- /dev/null +++ b/packages/app/src/i18n/es.json @@ -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" + } +} diff --git a/packages/app/src/i18n/fr.json b/packages/app/src/i18n/fr.json new file mode 100644 index 00000000..8e62aecd --- /dev/null +++ b/packages/app/src/i18n/fr.json @@ -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" + } +} diff --git a/packages/app/src/i18n/index.ts b/packages/app/src/i18n/index.ts new file mode 100644 index 00000000..30133f58 --- /dev/null +++ b/packages/app/src/i18n/index.ts @@ -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 = { 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)[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) +} diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css index 334201b8..7f3e84e7 100644 --- a/packages/app/src/styles/main.css +++ b/packages/app/src/styles/main.css @@ -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) {