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:
@@ -921,7 +921,12 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="chat-container welcome-active" id="chat-container" role="region" aria-label="Chat area" aria-busy="false">
|
||||
<main class="chat-container welcome-active" id="chat-container" aria-label="Chat area" aria-busy="false">
|
||||
<!-- Persistent page heading for assistive tech. Visually hidden so it
|
||||
never affects layout, but always present inside the main landmark
|
||||
(the sidebar that shows the visible brand is hidden off-canvas on
|
||||
mobile) so the page always exposes a single level-1 heading. -->
|
||||
<h1 class="a11y-visually-hidden">Odysseus</h1>
|
||||
<div class="chat-top-bar">
|
||||
<button type="button" class="incognito-indicator" id="incognito-indicator" title="Nobody mode active — click to deactivate" style="display:none;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg></button>
|
||||
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><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="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div></div></span></div> </div>
|
||||
@@ -2254,6 +2259,7 @@
|
||||
<script type="module" src="/static/js/assistant.js"></script>
|
||||
<script type="module" src="/static/app.js"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
165
static/js/a11y.js
Normal file
165
static/js/a11y.js
Normal file
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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…';
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -150,16 +150,23 @@
|
||||
color: var(--fg); font-size: 0.95rem; font-family: 'Fira Code', monospace;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--red); }
|
||||
/* Clear, visible focus ring for keyboard users on every focusable control. */
|
||||
input:focus-visible, a:focus-visible, button:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
/* Asymmetric vertical padding nudges the label 1px down while keeping
|
||||
the button's total height the same as 0.7rem all-around. */
|
||||
padding: calc(0.7rem + 1px) 0.7rem calc(0.7rem - 1px);
|
||||
border: none; border-radius: 6px;
|
||||
background: var(--red); color: #fff; font-size: 1rem; cursor: pointer;
|
||||
/* Darken the brand red slightly so #fff label text clears the WCAG AA
|
||||
4.5:1 contrast threshold (plain --red #e06c75 only reaches ~3.2:1). */
|
||||
background: color-mix(in srgb, var(--red) 78%, #000); color: #fff; font-size: 1rem; cursor: pointer;
|
||||
font-weight: 600; font-family: 'Fira Code', monospace;
|
||||
}
|
||||
button:hover { background: color-mix(in srgb, var(--red) 85%, black); }
|
||||
button:hover { background: color-mix(in srgb, var(--red) 66%, black); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.error { color: #e55; font-size: 0.85rem; margin-bottom: 0.75rem; display: none; }
|
||||
.toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
|
||||
@@ -185,7 +192,17 @@
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 0; margin: 0; color: transparent;
|
||||
}
|
||||
.remember-toggle .remember-check { display: none; }
|
||||
/* Visually hide the native checkbox but keep it in the accessibility tree
|
||||
and keyboard-focusable (display:none would drop it from tab order). It
|
||||
overlays the dot so a click/tap still toggles it. */
|
||||
.remember-toggle .remember-check {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%; margin: 0;
|
||||
opacity: 0; cursor: pointer;
|
||||
}
|
||||
.remember-toggle .remember-check:focus-visible + .remember-dot {
|
||||
outline: 2px solid var(--red); outline-offset: 2px;
|
||||
}
|
||||
.remember-toggle .remember-dot {
|
||||
display: block; width: 10px; height: 10px; min-width: 10px; min-height: 10px;
|
||||
border-radius: 50%;
|
||||
@@ -223,21 +240,21 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<svg class="logo-boat" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
|
||||
</div>
|
||||
<main class="card">
|
||||
<h1 class="logo">
|
||||
<svg class="logo-boat" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
|
||||
</h1>
|
||||
<p class="setup-note" id="setupNote" style="display:none"></p>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
<div class="error" id="error" role="alert" aria-live="assertive"></div>
|
||||
|
||||
<form id="authForm" autocomplete="on">
|
||||
<label for="username">Username</label>
|
||||
<div class="pw-wrapper">
|
||||
<input id="username" name="username" type="text" required autofocus autocomplete="username">
|
||||
<label class="remember-toggle" id="rememberToggle" title="Remember me">
|
||||
<input type="checkbox" class="remember-check" id="remember" checked>
|
||||
<span class="remember-dot"></span>
|
||||
<input type="checkbox" class="remember-check" id="remember" checked aria-label="Remember me">
|
||||
<span class="remember-dot" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -266,9 +283,9 @@
|
||||
<span id="toggleText">Don't have an account? </span>
|
||||
<a id="toggleLink" href="#">Sign up</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="version-label" id="version-label"></div>
|
||||
<footer class="version-label" id="version-label"></footer>
|
||||
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(async () => {
|
||||
@@ -468,7 +485,7 @@
|
||||
form._totpMode = true;
|
||||
const totpWrap = document.createElement('div');
|
||||
totpWrap.style.cssText = 'margin-top:12px;';
|
||||
totpWrap.innerHTML = '<label style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
|
||||
totpWrap.innerHTML = '<label for="totp-input" style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" aria-label="Two-factor authentication code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
|
||||
const formEl = submitBtn.parentElement;
|
||||
formEl.insertBefore(totpWrap, submitBtn);
|
||||
const totpInput = document.getElementById('totp-input');
|
||||
|
||||
@@ -265,7 +265,9 @@ body.bg-pattern-sparkles {
|
||||
transform: translateY(calc(-50% - 2px));
|
||||
font-size: 0.75em;
|
||||
line-height: 1;
|
||||
color: color-mix(in srgb, var(--fg) 40%, transparent);
|
||||
/* 70% mix keeps the chat title clearly above the WCAG AA 4.5:1
|
||||
contrast threshold (40% only reached ~2.8:1). */
|
||||
color: color-mix(in srgb, var(--fg) 70%, transparent);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2550,7 +2552,9 @@ body.bg-pattern-sparkles {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: color-mix(in srgb, var(--fg) 40%, transparent);
|
||||
/* 65% mix lifts the model label above the WCAG AA 4.5:1 threshold
|
||||
against the dark chat-bar (40% only reached ~2.9:1). */
|
||||
color: color-mix(in srgb, var(--fg) 65%, transparent);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
@@ -9610,7 +9614,8 @@ details a:hover {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
/* 65% keeps this description text above WCAG AA 4.5:1 (50% was ~3.9:1). */
|
||||
color: color-mix(in srgb, var(--fg) 65%, transparent);
|
||||
}
|
||||
|
||||
.memory-add-row {
|
||||
@@ -11152,6 +11157,17 @@ textarea.memory-add-input {
|
||||
#doc-language-icon:empty { display: none; }
|
||||
#doc-language-icon svg { display: block; }
|
||||
|
||||
/* Visually hidden but available to assistive tech (screen readers, axe).
|
||||
Use for content that should be announced/structural but not painted —
|
||||
e.g. the persistent page <h1>. */
|
||||
.a11y-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important; height: 1px !important;
|
||||
padding: 0 !important; margin: -1px !important;
|
||||
overflow: hidden !important; clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important; border: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Custom language type picker (replaces visible chrome of native <select>
|
||||
— <option>s can't render SVG). Hidden select stays as the source of truth. */
|
||||
.doc-langpicker-native-hidden {
|
||||
@@ -12929,7 +12945,9 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
font-weight: 500;
|
||||
}
|
||||
.admin-toggle-sub {
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
/* 65% mix keeps this helper text above WCAG AA 4.5:1 on the dark panel
|
||||
(50% only reached ~3.9:1). */
|
||||
color: color-mix(in srgb, var(--fg) 65%, transparent);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -29699,7 +29717,9 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
|
||||
}
|
||||
.notes-empty-msg {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
/* 0.4 dropped this empty-state text to ~2.8:1; 0.65 keeps it readable
|
||||
(WCAG AA) while staying visibly secondary. */
|
||||
opacity: 0.65;
|
||||
padding: 30px 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user