mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.
Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
9.1 KiB
JavaScript
226 lines
9.1 KiB
JavaScript
// 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',
|
|
'.sidebar-brand',
|
|
'.export-dropdown-item',
|
|
'.dropdown-item'
|
|
].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);
|
|
}
|
|
|
|
// Settings form labels are written as <label class="settings-label">Text</label>
|
|
// with the control as a sibling/descendant, but are not associated via for/id,
|
|
// so screen readers cannot name the inputs. Associate each label with its
|
|
// control when the mapping is unambiguous (a single form control per row).
|
|
var settingsLabelSeq = 0;
|
|
function associateSettingsLabels(root) {
|
|
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
|
|
if (label.hasAttribute('for')) return;
|
|
var control = null;
|
|
var sib = label.nextElementSibling;
|
|
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
|
|
control = sib;
|
|
} else {
|
|
var row = label.closest('.settings-row') || label.parentElement;
|
|
if (row) {
|
|
var controls = row.querySelectorAll('input,select,textarea');
|
|
if (controls.length === 1) control = controls[0];
|
|
}
|
|
}
|
|
if (!control || control.closest('label')) return;
|
|
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
|
|
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
|
|
});
|
|
}
|
|
|
|
// ---- 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();
|
|
});
|
|
|
|
// Disclosure buttons: keep aria-expanded in sync with their controlled
|
|
// menu/dropdown, regardless of which module toggles its visibility.
|
|
var DISCLOSURE_MAP = [
|
|
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
|
|
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
|
|
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
|
|
];
|
|
|
|
function syncDisclosures() {
|
|
DISCLOSURE_MAP.forEach(function (d) {
|
|
var btn = document.getElementById(d.button);
|
|
if (!btn) return;
|
|
var menu = document.getElementById(d.menu);
|
|
var open = false;
|
|
if (menu) {
|
|
var cs = window.getComputedStyle(menu);
|
|
open = cs.display !== 'none' && menu.offsetWidth > 0;
|
|
}
|
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
enhanceAll(document);
|
|
enhanceModals(document);
|
|
associateSettingsLabels(document);
|
|
|
|
// Any click (the dropdown toggles, outside-click closes, etc.) may change
|
|
// a disclosure's open state; re-sync on the next tick.
|
|
document.addEventListener('click', function () {
|
|
if (window.setTimeout) setTimeout(syncDisclosures, 0);
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
})();
|