mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Improve accessibility across core flows (#86)
First incremental pass at issue #86, focused on the universal entry points and primary navigation. All changes verified in-browser with the axe-core engine (0 violations on the surfaces below) plus manual keyboard testing, on both desktop (1280px) and mobile (390px). Login / first-run setup (static/login.html) - Add a real <h1>, wrap content in <main> + <footer> landmarks. - Mark the decorative boat SVG aria-hidden. - Errors now use role="alert" so screen readers announce them. - "Remember me" checkbox is keyboard-focusable (was display:none) with an accessible name and a focus ring; dynamic 2FA field gets a linked label. - Darken the brand-red submit button so white text clears WCAG AA 4.5:1 (was ~3.2:1); add visible :focus-visible rings. App shell (static/index.html, static/style.css) - Remove invalid role="region" from the <main> chat container (it was overriding the implicit main landmark). - Add a persistent, visually-hidden <h1> inside <main> so the page always exposes one logical level-1 heading — works even on mobile where the sidebar (with the visible brand) is hidden off-canvas. - Add a reusable .a11y-visually-hidden utility. - Raise chat-title, model-picker, settings-helper and notes text contrast above 4.5:1 (were 2.8-3.9:1). Keyboard nav + dialogs (static/js/a11y.js - new) - Make the click-only <div> sidebar navigation (New Chat, Search, Brain, Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes, Tasks, Theme, account) focusable and Enter/Space-activatable, announced as buttons (skipping role=button where a nested control would create a nested-interactive violation). Visible focus ring reused from existing .list-item:focus-visible. - Upgrade modals (.modal-content and the docked .notes-pane) to labelled role="dialog" + aria-modal, and normalise their title to heading level 2 so heading order stays valid. A MutationObserver covers runtime-rendered rows and modals. Decorative background canvases (static/js/theme.js) - Mark all 7 bg-effect canvases aria-hidden. Notes & Tasks (static/js/notes.js, static/js/tasks.js) - Label the icon-only Note/To-do toggle pills (fixes a critical button-name issue) and track aria-pressed state. - Improve Notes header-button + empty-state contrast. - Give the Tasks sort <select> an accessible name (fixes a critical select-name issue). Remaining data-dense tool modals (Tasks cards, Calendar, Gallery, Email, Cookbook, Compare, Deep Research) still have muted-text contrast to polish and are the next incremental step, per the issue's own guidance.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// Accessibility enhancements for keyboard + screen-reader users.
|
||||
//
|
||||
// Several primary controls in Odysseus are authored as click-only <div>s
|
||||
// (most notably the whole sidebar navigation: New Chat, Search, Brain,
|
||||
// Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes,
|
||||
// Tasks, Theme, plus the account row). <div>s are not in the tab order and
|
||||
// are not announced as buttons, so keyboard and screen-reader users cannot
|
||||
// reach or operate them.
|
||||
//
|
||||
// This module enhances those rows in place — making them focusable
|
||||
// (tabindex=0), announcing them as buttons when it's safe to do so, and
|
||||
// activating them with Enter / Space — without changing how they look or
|
||||
// how they behave for mouse users. The visible focus ring already exists in
|
||||
// style.css (`.list-item:focus-visible`); it simply never fired because the
|
||||
// rows were never focusable.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Click-as-button rows we want reachable by keyboard.
|
||||
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
|
||||
|
||||
// Native interactive descendants. If a row contains one of these we must
|
||||
// NOT give the row role="button" — a button inside a button is invalid
|
||||
// (axe "nested-interactive") and confuses screen readers. Such rows still
|
||||
// become focusable + Enter/Space-activatable, just without the role.
|
||||
var NESTED_INTERACTIVE =
|
||||
'a[href],button,input,select,textarea,[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function enhanceRow(el) {
|
||||
if (!el || el.nodeType !== 1 || el.dataset.a11yEnhanced === '1') return;
|
||||
var tag = el.tagName;
|
||||
// Leave genuine native controls alone.
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
|
||||
tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||
|
||||
el.dataset.a11yEnhanced = '1';
|
||||
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('data-a11y-activatable', '1');
|
||||
|
||||
if (!el.querySelector(NESTED_INTERACTIVE) && !el.hasAttribute('role')) {
|
||||
el.setAttribute('role', 'button');
|
||||
}
|
||||
|
||||
// Guarantee an accessible name. Visible text normally supplies it; fall
|
||||
// back to the title attribute for icon-only rows.
|
||||
if (!el.getAttribute('aria-label') &&
|
||||
!(el.textContent || '').trim() &&
|
||||
el.getAttribute('title')) {
|
||||
el.setAttribute('aria-label', el.getAttribute('title'));
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceAll(root) {
|
||||
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
|
||||
}
|
||||
|
||||
// ---- Modal dialogs -----------------------------------------------------
|
||||
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
|
||||
// them as ARIA dialogs lets screen readers announce them as dialogs and
|
||||
// exempts their content from the "all content in landmarks" rule. We also
|
||||
// normalize the modal title to heading level 2 (one below the page <h1>)
|
||||
// so heading order stays valid no matter which tag the markup uses.
|
||||
var titleSeq = 0;
|
||||
// Each modal "kind" is a container selector plus where to find its title
|
||||
// heading. Standard modals use .modal-content/.modal-header; the docked
|
||||
// Notes pane uses its own markup.
|
||||
var MODAL_KINDS = [
|
||||
{
|
||||
sel: '.modal-content',
|
||||
heading: '.modal-header h1, .modal-header h2, .modal-header h3, ' +
|
||||
'.modal-header h4, .modal-header h5, .modal-header h6'
|
||||
},
|
||||
{ sel: '.notes-pane', heading: '.notes-pane-title' }
|
||||
];
|
||||
var MODAL_SEL = MODAL_KINDS.map(function (k) { return k.sel; }).join(',');
|
||||
|
||||
function enhanceModal(mc, headingSel) {
|
||||
if (!mc || mc.nodeType !== 1 || mc.dataset.a11yDialog === '1') return;
|
||||
mc.dataset.a11yDialog = '1';
|
||||
if (!mc.hasAttribute('role')) mc.setAttribute('role', 'dialog');
|
||||
if (!mc.hasAttribute('aria-modal')) mc.setAttribute('aria-modal', 'true');
|
||||
|
||||
var heading = headingSel && mc.querySelector(headingSel);
|
||||
if (heading) {
|
||||
if (!heading.id) heading.id = 'a11y-modal-title-' + (++titleSeq);
|
||||
if (!mc.hasAttribute('aria-labelledby')) {
|
||||
mc.setAttribute('aria-labelledby', heading.id);
|
||||
}
|
||||
// Modal titles sit one level below the page <h1>; normalize so heading
|
||||
// order stays valid regardless of the tag the markup happens to use.
|
||||
if (!heading.hasAttribute('aria-level')) heading.setAttribute('aria-level', '2');
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceModals(root) {
|
||||
var scope = root || document;
|
||||
MODAL_KINDS.forEach(function (k) {
|
||||
scope.querySelectorAll(k.sel).forEach(function (mc) { enhanceModal(mc, k.heading); });
|
||||
});
|
||||
}
|
||||
|
||||
function headingSelFor(el) {
|
||||
for (var i = 0; i < MODAL_KINDS.length; i++) {
|
||||
if (el.matches(MODAL_KINDS[i].sel)) return MODAL_KINDS[i].heading;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delegated keyboard activation. We only act when the focused element is
|
||||
// itself an enhanced row (keydown targets the focused element), so a press
|
||||
// on a nested native button is left to the browser's own handling.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
||||
var el = e.target;
|
||||
if (!el || !el.matches || !el.matches('[data-a11y-activatable]')) return;
|
||||
e.preventDefault(); // Space would otherwise scroll the page
|
||||
el.click();
|
||||
});
|
||||
|
||||
function init() {
|
||||
enhanceAll(document);
|
||||
enhanceModals(document);
|
||||
|
||||
// Sidebar content is re-rendered as the user navigates (session lists,
|
||||
// tool sub-rows, etc.). Watch for new rows and enhance them too.
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && 'MutationObserver' in window) {
|
||||
new MutationObserver(function (muts) {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
var added = muts[i].addedNodes;
|
||||
for (var j = 0; j < added.length; j++) {
|
||||
var n = added[j];
|
||||
if (n.nodeType !== 1) continue;
|
||||
if (n.matches && n.matches(ROW_SELECTOR)) enhanceRow(n);
|
||||
if (n.querySelectorAll) enhanceAll(n);
|
||||
}
|
||||
}
|
||||
}).observe(sidebar, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// Some modals (Notes, Tasks, …) are injected at runtime, usually as
|
||||
// direct children of <body>. Catch those without paying for a deep
|
||||
// subtree observer over the whole document.
|
||||
if ('MutationObserver' in window) {
|
||||
new MutationObserver(function (muts) {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
var added = muts[i].addedNodes;
|
||||
for (var j = 0; j < added.length; j++) {
|
||||
var n = added[j];
|
||||
if (n.nodeType !== 1) continue;
|
||||
if (n.matches && n.matches(MODAL_SEL)) enhanceModal(n, headingSelFor(n));
|
||||
if (n.querySelector && n.querySelector(MODAL_SEL)) enhanceModals(n);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+11
-9
@@ -1118,11 +1118,11 @@ export function openPanel() {
|
||||
<div class="notes-pane-header">
|
||||
<h4 class="notes-pane-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2.5px;margin-right:6px"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>Notes</h4>
|
||||
<span style="flex:1"></span>
|
||||
<button id="notes-archive-toggle" class="doc-action-icon-btn notes-header-text-btn" title="View archive" style="opacity:0.6;gap:5px;">
|
||||
<button id="notes-archive-toggle" class="doc-action-icon-btn notes-header-text-btn" title="View archive" style="opacity:0.8;gap:5px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>
|
||||
<span class="notes-header-btn-label">Archive</span>
|
||||
</button>
|
||||
<button id="notes-view-toggle" class="doc-action-icon-btn notes-header-text-btn" title="Toggle view" style="opacity:0.6;gap:5px;">
|
||||
<button id="notes-view-toggle" class="doc-action-icon-btn notes-header-text-btn" title="Toggle view" style="opacity:0.8;gap:5px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
<span class="notes-header-btn-label">Toggle</span>
|
||||
</button>
|
||||
@@ -1214,7 +1214,7 @@ export function openPanel() {
|
||||
const syncArchiveBtn = () => {
|
||||
archiveBtn.classList.toggle('active', _showingArchived);
|
||||
archiveBtn.title = _showingArchived ? 'Exit archive' : 'View archive';
|
||||
archiveBtn.style.opacity = _showingArchived ? '1' : '0.6';
|
||||
archiveBtn.style.opacity = _showingArchived ? '1' : '0.8';
|
||||
// Swap to an X while in archive view so it doubles as a close-back-
|
||||
// to-active-notes toggle.
|
||||
archiveBtn.innerHTML = _showingArchived ? CLOSE_ICON : ARCHIVE_ICON;
|
||||
@@ -2022,12 +2022,12 @@ function _renderQuickAdd(body) {
|
||||
// drawing happens in the expanded form). The pill that's active steers
|
||||
// both the placeholder and the type the form opens in.
|
||||
wrap.innerHTML = `
|
||||
<div class="notes-quick-type-seg is-todo" role="group">
|
||||
<button type="button" class="notes-quick-type-pill" data-type="note">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
|
||||
<div class="notes-quick-type-seg is-todo" role="group" aria-label="New item type">
|
||||
<button type="button" class="notes-quick-type-pill" data-type="note" aria-label="Note" aria-pressed="false" title="Note">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="notes-quick-type-pill active" data-type="todo">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<button type="button" class="notes-quick-type-pill active" data-type="todo" aria-label="To-do" aria-pressed="true" title="To-do">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" class="notes-quick-input" placeholder="Add a to-do…" />
|
||||
@@ -2046,7 +2046,9 @@ function _renderQuickAdd(body) {
|
||||
seg.classList.toggle('is-todo', t === 'todo');
|
||||
seg.classList.toggle('is-note', t === 'note');
|
||||
seg.querySelectorAll('.notes-quick-type-pill').forEach(p => {
|
||||
p.classList.toggle('active', p.dataset.type === t);
|
||||
const on = p.dataset.type === t;
|
||||
p.classList.toggle('active', on);
|
||||
p.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…';
|
||||
};
|
||||
|
||||
+1
-1
@@ -2401,7 +2401,7 @@ function _renderMainView() {
|
||||
<p class="memory-desc" style="position:relative;top:-4px;">Scheduled prompts and actions that run automatically. Results appear in a dedicated session.</p>
|
||||
<div class="memory-toolbar">
|
||||
<div class="memory-category-filters" style="display:flex;align-items:center;gap:6px;">
|
||||
<select class="memory-sort-select" id="tasks-sort" style="position:relative;top:-4px;width:86px;font-size:11px;height:24px;">
|
||||
<select class="memory-sort-select" id="tasks-sort" aria-label="Sort tasks" title="Sort tasks" style="position:relative;top:-4px;width:86px;font-size:11px;height:24px;">
|
||||
<option value="recent">Recent</option>
|
||||
<option value="name">A–Z</option>
|
||||
<option value="status">Status</option>
|
||||
|
||||
@@ -1495,6 +1495,9 @@ function _initSynapse() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'synapse-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -1588,6 +1591,9 @@ function _initRain() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'rain-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -1660,6 +1666,9 @@ function _initConstellations() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'constellations-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -1763,6 +1772,9 @@ function _initPerlinFlow() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'perlin-flow-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -1818,6 +1830,9 @@ function _initPetals() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'petals-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -1872,6 +1887,9 @@ function _initSparkles() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'sparkles-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -1927,6 +1945,9 @@ function _initEmbers() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'embers-canvas';
|
||||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
|
||||
// Decorative background effect — hide from assistive tech so screen readers
|
||||
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
document.body.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
Reference in New Issue
Block a user