diff --git a/public/assets/css/accessibility.css b/public/assets/css/accessibility.css new file mode 100644 index 0000000..91e5fbf --- /dev/null +++ b/public/assets/css/accessibility.css @@ -0,0 +1,168 @@ +/* ═════════════════════════════════════════════════════════════ + StellarMind Accessibility Enhancements + Keyboard navigation, focus states, ARIA support, skip link + Closes #35 + ═════════════════════════════════════════════════════════════ */ + +/* ── Skip Navigation Link ─────────────────────────────────── */ +.skip-link { + position: absolute; + top: -100%; + left: 8px; + z-index: 9999; + padding: 10px 18px; + background: var(--accent); + color: #fff; + font-weight: 600; + font-size: 14px; + border-radius: var(--radius-sm); + text-decoration: none; + transition: top 0.15s ease; +} + +.skip-link:focus { + top: 8px; + outline: 3px solid var(--accent-light); + outline-offset: 2px; +} + +/* ── Focus Indicators (WCAG 2.1 AA — 2.4.7) ───────────────── */ +*:focus-visible { + outline: 2px solid var(--accent-light); + outline-offset: 2px; + border-radius: 3px; +} + +/* Remove default focus for mouse users, keep for keyboard */ +*:focus:not(:focus-visible) { + outline: none; +} + +/* ── Interactive Controls ─────────────────────────────────── */ +button:focus-visible, +a:focus-visible, +[role="button"]:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + box-shadow: 0 0 0 4px var(--accent-glow); +} + +/* ── Sidebar Navigation ───────────────────────────────────── */ +.sidebar nav a:focus-visible { + outline: 2px solid var(--accent-light); + outline-offset: -2px; + border-radius: var(--radius-xs); + background: var(--bg-hover); +} + +/* ── Modal / Panel Focus Trap ─────────────────────────────── */ +.modal:focus, +.panel:focus, +[role="dialog"]:focus { + outline: none; +} + +.modal[aria-modal="true"] :focus-visible, +[role="dialog"] :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── Reduced Motion ───────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ── High Contrast Mode ───────────────────────────────────── */ +@media (forced-colors: active) { + .sidebar { + border-right: 1px solid ButtonText; + } + + button, + [role="button"], + .card { + border: 1px solid ButtonText; + } + + *:focus-visible { + outline: 3px solid Highlight; + } +} + +/* ── Screen Reader Only ───────────────────────────────────── */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ── Live Region Announcer ────────────────────────────────── */ +[aria-live="polite"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +/* ── Form Field Labels ────────────────────────────────────── */ +label { + display: block; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-secondary); +} + +input[aria-invalid="true"], +textarea[aria-invalid="true"], +select[aria-invalid="true"] { + border-color: var(--red); + box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15); +} + +[role="alert"] { + color: var(--red); + font-size: 13px; + margin-top: 4px; +} + +/* ── Color Contrast ───────────────────────────────────────── */ +.text-muted { + color: var(--text-secondary); /* Was #505268 — bumped for AA compliance */ +} + +/* ── Tooltip / Disclosure ─────────────────────────────────── */ +[aria-expanded]::after { + content: ''; + display: inline-block; + width: 0; + height: 0; + margin-left: 6px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid currentColor; + vertical-align: middle; + transition: transform var(--tr); +} + +[aria-expanded="true"]::after { + transform: rotate(180deg); +} diff --git a/public/assets/js/accessibility.js b/public/assets/js/accessibility.js new file mode 100644 index 0000000..cf1d9d0 --- /dev/null +++ b/public/assets/js/accessibility.js @@ -0,0 +1,167 @@ +/** + * StellarMind Accessibility Module + * Keyboard navigation, focus management, ARIA live regions + * Closes #35 + */ +(function () { + 'use strict'; + + // ── Skip Link Injection ────────────────────────────────────── + function injectSkipLink() { + const skip = document.createElement('a'); + skip.href = '#main-content'; + skip.className = 'skip-link'; + skip.textContent = 'Skip to main content'; + document.body.prepend(skip); + } + + // ── Main Content Anchor ────────────────────────────────────── + function ensureMainContentAnchor() { + let main = document.querySelector('#main-content'); + if (!main) { + const contentArea = document.querySelector('.main-content, main, [role="main"]'); + if (contentArea) { + contentArea.id = 'main-content'; + contentArea.setAttribute('tabindex', '-1'); + } else { + // Fallback: wrap main area + const wrapper = document.querySelector('.content, .page-content, .dashboard-content'); + if (wrapper) { + wrapper.id = 'main-content'; + wrapper.setAttribute('tabindex', '-1'); + } + } + } + } + + // ── Live Region Announcer ──────────────────────────────────── + const announcer = (function () { + let el = document.getElementById('a11y-announcer'); + if (!el) { + el = document.createElement('div'); + el.id = 'a11y-announcer'; + el.setAttribute('aria-live', 'polite'); + el.setAttribute('aria-atomic', 'true'); + el.className = 'sr-only'; + document.body.appendChild(el); + } + return { + announce: function (message) { + el.textContent = ''; + // Force re-announce by clearing then setting + requestAnimationFrame(() => { + el.textContent = message; + }); + }, + }; + })(); + + // ── Focus Trap for Modals ──────────────────────────────────── + function trapFocus(container) { + const focusable = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + function handleKeyDown(e) { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + + container.addEventListener('keydown', handleKeyDown); + first.focus(); + + // Return cleanup function + return () => container.removeEventListener('keydown', handleKeyDown); + } + + // ── Observe Modal Openings ─────────────────────────────────── + function observeModals() { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== 1) return; + const dialogs = node.querySelectorAll + ? node.querySelectorAll('[role="dialog"], [aria-modal="true"], .modal') + : []; + dialogs.forEach((dialog) => { + if (dialog.getAttribute('aria-modal') === 'true') { + trapFocus(dialog); + } + }); + // Also check if node itself is a modal + if ( + node.getAttribute && + (node.getAttribute('aria-modal') === 'true' || + node.getAttribute('role') === 'dialog' || + node.classList.contains('modal')) + ) { + trapFocus(node); + } + }); + + // Announce page changes + if (mutation.target && mutation.target.getAttribute('aria-live') === 'polite') { + return; // Don't double-announce + } + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return observer; + } + + // ── Keyboard Navigation for Cards / Panels ────────────────── + function enhanceCardKeyboardNav() { + document.addEventListener('keydown', (e) => { + // Enter/Space on card-like elements + if (e.key === 'Enter' || e.key === ' ') { + const target = e.target.closest('[role="button"], .card[tabindex]'); + if (target && target !== e.target) { + e.preventDefault(); + target.click(); + } + } + }); + } + + // ── Initialize ────────────────────────────────────────────── + function init() { + injectSkipLink(); + ensureMainContentAnchor(); + observeModals(); + enhanceCardKeyboardNav(); + + // Expose announcer globally for page scripts + window.__a11y = { + announce: announcer.announce, + trapFocus: trapFocus, + }; + + console.log('[a11y] Accessibility module initialized'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();