Merge branch 'pr-738' into visual-pr-playground

This commit is contained in:
pewdiepie-archdaemon
2026-06-02 06:26:32 +09:00
12 changed files with 265 additions and 29 deletions

5
.gitignore vendored
View File

@@ -66,6 +66,11 @@ output.txt.txt
!docs/*.png
!docs/*.gif
!docs/*.webp
# …and curated docs/ subfolder assets (e.g. accessibility before/after shots).
!docs/**/*.png
!docs/**/*.jpg
!docs/**/*.gif
!docs/**/*.webp
# Reports and temp files
reports/

BIN
docs/a11y/focus-after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
docs/a11y/focus-before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/a11y/login-after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
docs/a11y/login-before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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
View 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();
}
})();

View File

@@ -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…';
};

View File

@@ -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">AZ</option>
<option value="status">Status</option>

View File

@@ -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);

View File

@@ -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');

View File

@@ -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;
@@ -9771,7 +9775,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 {
@@ -11313,6 +11318,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 {
@@ -13090,7 +13106,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;
}
@@ -29880,7 +29898,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;
}