Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions public/assets/css/accessibility.css
Original file line number Diff line number Diff line change
@@ -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);
}
167 changes: 167 additions & 0 deletions public/assets/js/accessibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* StellarMind Accessibility Module
* Keyboard navigation, focus management, ARIA live regions
* Closes #35
*/
(function () {

Check failure on line 6 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Insert `;`

Check failure on line 6 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Insert `;`
'use strict';

Check failure on line 7 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 7 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

// ── Skip Link Injection ──────────────────────────────────────
function injectSkipLink() {
const skip = document.createElement('a');

Check failure on line 11 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 11 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
skip.href = '#main-content';

Check failure on line 12 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 12 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
skip.className = 'skip-link';

Check failure on line 13 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 13 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
skip.textContent = 'Skip to main content';

Check failure on line 14 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 14 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
document.body.prepend(skip);

Check failure on line 15 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 15 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
}

// ── Main Content Anchor ──────────────────────────────────────
function ensureMainContentAnchor() {
let main = document.querySelector('#main-content');

Check failure on line 20 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 20 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
if (!main) {
const contentArea = document.querySelector('.main-content, main, [role="main"]');

Check failure on line 22 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 22 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
if (contentArea) {
contentArea.id = 'main-content';

Check failure on line 24 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`

Check failure on line 24 in public/assets/js/accessibility.js

View workflow job for this annotation

GitHub Actions / lint

Delete `;`
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();
}
})();
Loading