Accessibility: add labels and toggle states

* Accessibility: ARIA labels and toggle states

Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.

- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
  overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
  search, memory sort, theme font/density selects, and the new-memory / skill
  (title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
  sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
  (initModeToggle). Static aria-pressed added in the markup so the attribute
  exists before JS runs.

Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.

Checks: node --check static/app.js. No Python touched.

* Accessibility: mark chat log busy while streaming

The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.

Checks: node --check static/js/chat.js.
This commit is contained in:
Kenny Van de Maele
2026-06-02 13:55:05 +02:00
committed by GitHub
parent aa0a9e8b5a
commit cfb7ec1c71
3 changed files with 47 additions and 35 deletions

View File

@@ -1564,6 +1564,8 @@ function initializeEventListeners() {
saveToggleState(st);
agentBtn.classList.toggle('active', mode === 'agent');
chatBtn.classList.toggle('active', mode === 'chat');
agentBtn.setAttribute('aria-pressed', String(mode === 'agent'));
chatBtn.setAttribute('aria-pressed', String(mode === 'chat'));
// Slide the pill to the active button
const toggle = agentBtn.closest('.mode-toggle');
if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat');
@@ -1621,11 +1623,13 @@ function initializeEventListeners() {
const chk = el(checkboxId);
if (chk) chk.checked = saved;
btn.classList.toggle('active', saved);
btn.setAttribute('aria-pressed', String(saved));
btn.addEventListener('click', () => {
const curMode = (loadToggleState().mode) || 'chat';
const chk = el(checkboxId);
chk.checked = !chk.checked;
btn.classList.toggle('active', chk.checked);
btn.setAttribute('aria-pressed', String(chk.checked));
saveToolPref(stateKey, curMode, chk.checked);
showToolToggleToast(stateKey, chk.checked);
if (chk.checked) _showToolSplash(stateKey);

View File

@@ -265,7 +265,7 @@
<p class="memory-desc doclib-desc" style="margin-top:6px;">Long-term facts the AI remembers across chats — recall, edit, or curate.</p>
<div class="memory-toolbar">
<div class="memory-toolbar-row">
<select id="memory-sort" class="memory-sort-select">
<select id="memory-sort" class="memory-sort-select" aria-label="Sort memories">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="alpha">A-Z</option>
@@ -274,7 +274,7 @@
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories">Select</button>
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
</div>
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" />
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" aria-label="Search memories" />
<div id="memory-category-filters" class="memory-category-filters">
<button class="memory-cat-chip active" data-cat="all">all</button>
</div>
@@ -304,7 +304,7 @@
</p>
<div class="memory-add-row" style="margin-top:8px;">
<div class="skill-ph-wrap" style="flex:1;min-width:0;">
<input type="text" id="new-memory-input" placeholder=" " class="memory-add-input skill-hint-input" />
<input type="text" id="new-memory-input" placeholder=" " class="memory-add-input skill-hint-input" aria-label="New memory text" />
<span class="skill-rich-ph"><span class="k">Add a memory</span> &mdash; e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
</div>
</div>
@@ -315,19 +315,19 @@
</div>
<p class="memory-desc doclib-desc" style="margin-top:6px;">Create a skill by hand — title, what it solves, and an approach.</p>
<div class="skill-ph-wrap" style="margin-top:4px;margin-bottom:6px;">
<input type="text" id="new-skill-title" placeholder=" " class="memory-add-input skill-hint-input" />
<input type="text" id="new-skill-title" placeholder=" " class="memory-add-input skill-hint-input" aria-label="Skill title" />
<span class="skill-rich-ph"><span class="k">Title</span> — short name, e.g. “build-vllm-wheel”</span>
</div>
<div class="skill-ph-wrap" style="margin-bottom:6px;">
<input type="text" id="new-skill-problem" placeholder=" " class="memory-add-input skill-hint-input" />
<input type="text" id="new-skill-problem" placeholder=" " class="memory-add-input skill-hint-input" aria-label="When to use this skill" />
<span class="skill-rich-ph"><span class="k">When to use</span> — what problem does this skill solve?</span>
</div>
<div class="skill-ph-wrap" style="margin-bottom:6px;">
<textarea id="new-skill-solution" placeholder=" " class="memory-add-input skill-hint-input" rows="2" style="resize:vertical;"></textarea>
<textarea id="new-skill-solution" placeholder=" " class="memory-add-input skill-hint-input" rows="2" style="resize:vertical;" aria-label="How — the approach or steps"></textarea>
<span class="skill-rich-ph skill-rich-ph-top"><span class="k">How</span> — the approach, steps, commands, or rules to follow</span>
</div>
<div class="skill-ph-wrap" style="margin-bottom:8px;">
<input type="text" id="new-skill-tags" placeholder=" " class="memory-add-input skill-hint-input" />
<input type="text" id="new-skill-tags" placeholder=" " class="memory-add-input skill-hint-input" aria-label="Tags" />
<span class="skill-rich-ph"><span class="k">Tags</span> — comma-separated, e.g. python, build, vllm</span>
</div>
<div style="display:flex;justify-content:flex-end;">
@@ -368,7 +368,7 @@
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills">Select</button>
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Audit all</button>
</div>
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" />
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" aria-label="Search skills" />
</div>
<div id="skills-audit-panel" class="skills-audit-panel hidden"></div>
<div id="skills-bulk-bar" class="memory-bulk-bar hidden">
@@ -407,7 +407,7 @@
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
<span class="admin-toggle-sub" style="margin:0">Max skills per request</span>
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" aria-label="Max skills to inject" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.5">Set to 0 to disable skill injection.</span>
</div>
@@ -464,12 +464,12 @@
<div class="admin-card">
<h2>Colors</h2>
<div class="theme-custom" id="themeCustom">
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Panel</label><input type="color" id="clr-panel"><button class="color-reset-btn" data-reset="panel" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Sidebar</label><input type="color" id="adv-sidebarBg"><button class="color-reset-btn" data-reset-adv="sidebarBg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Border</label><input type="color" id="clr-border"><button class="color-reset-btn" data-reset="border" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Accent</label><input type="color" id="clr-red"><button class="color-reset-btn" data-reset="red" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Panel</label><input type="color" id="clr-panel"><button class="color-reset-btn" data-reset="panel" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Sidebar</label><input type="color" id="adv-sidebarBg"><button class="color-reset-btn" data-reset-adv="sidebarBg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Border</label><input type="color" id="clr-border"><button class="color-reset-btn" data-reset="border" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Accent</label><input type="color" id="clr-red"><button class="color-reset-btn" data-reset="red" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-toggle" id="theme-adv-toggle">
@@ -479,38 +479,38 @@
<div class="theme-adv-group">
<div class="theme-adv-group-label">Chat Bubbles</div>
<div class="theme-custom">
<div class="color-row"><label>User Chat Bubble</label><input type="color" id="adv-userBubbleBg"><button class="color-reset-btn" data-reset-adv="userBubbleBg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>AI Chat Bubble</label><input type="color" id="adv-aiBubbleBg"><button class="color-reset-btn" data-reset-adv="aiBubbleBg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Border Chat Bubble</label><input type="color" id="adv-bubbleBorder"><button class="color-reset-btn" data-reset-adv="bubbleBorder" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>User Chat Bubble</label><input type="color" id="adv-userBubbleBg"><button class="color-reset-btn" data-reset-adv="userBubbleBg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>AI Chat Bubble</label><input type="color" id="adv-aiBubbleBg"><button class="color-reset-btn" data-reset-adv="aiBubbleBg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Border Chat Bubble</label><input type="color" id="adv-bubbleBorder"><button class="color-reset-btn" data-reset-adv="bubbleBorder" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-group">
<div class="theme-adv-group-label">Sidebar</div>
<div class="theme-custom">
<div class="color-row"><label>Odysseus Logo</label><input type="color" id="adv-brandColor"><button class="color-reset-btn" data-reset-adv="brandColor" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label title="Hamburger menu"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></label><input type="color" id="adv-hamburgerColor"><button class="color-reset-btn" data-reset-adv="hamburgerColor" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Odysseus Logo</label><input type="color" id="adv-brandColor"><button class="color-reset-btn" data-reset-adv="brandColor" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label title="Hamburger menu"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></label><input type="color" id="adv-hamburgerColor"><button class="color-reset-btn" data-reset-adv="hamburgerColor" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-group">
<div class="theme-adv-group-label">Chat Input / Prompt Area</div>
<div class="theme-custom">
<div class="color-row"><label>Input Bg</label><input type="color" id="adv-inputBg"><button class="color-reset-btn" data-reset-adv="inputBg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Input Border</label><input type="color" id="adv-inputBorder"><button class="color-reset-btn" data-reset-adv="inputBorder" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Send Btn</label><input type="color" id="adv-sendBtnBg"><button class="color-reset-btn" data-reset-adv="sendBtnBg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Send Hover</label><input type="color" id="adv-sendBtnHover"><button class="color-reset-btn" data-reset-adv="sendBtnHover" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Input Bg</label><input type="color" id="adv-inputBg"><button class="color-reset-btn" data-reset-adv="inputBg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Input Border</label><input type="color" id="adv-inputBorder"><button class="color-reset-btn" data-reset-adv="inputBorder" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Send Btn</label><input type="color" id="adv-sendBtnBg"><button class="color-reset-btn" data-reset-adv="sendBtnBg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Send Hover</label><input type="color" id="adv-sendBtnHover"><button class="color-reset-btn" data-reset-adv="sendBtnHover" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-group">
<div class="theme-adv-group-label">Code Blocks</div>
<div class="theme-custom">
<div class="color-row"><label>Code Bg</label><input type="color" id="adv-codeBg"><button class="color-reset-btn" data-reset-adv="codeBg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Code Text</label><input type="color" id="adv-codeFg"><button class="color-reset-btn" data-reset-adv="codeFg" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Code Bg</label><input type="color" id="adv-codeBg"><button class="color-reset-btn" data-reset-adv="codeBg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Code Text</label><input type="color" id="adv-codeFg"><button class="color-reset-btn" data-reset-adv="codeFg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-group">
<div class="theme-adv-group-label">Controls</div>
<div class="theme-custom">
<div class="color-row"><label>Toggle On</label><input type="color" id="adv-toggleActive"><button class="color-reset-btn" data-reset-adv="toggleActive" title="Reset this color">&#x21BA;</button></div>
<div class="color-row"><label>Toggle On</label><input type="color" id="adv-toggleActive"><button class="color-reset-btn" data-reset-adv="toggleActive" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-group">
@@ -559,7 +559,7 @@
<div class="theme-fd-row">
<div class="theme-fd-group">
<label class="theme-fd-label">Font</label>
<select id="theme-font-select" class="theme-fd-select">
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
<option value="mono">Monospace</option>
<option value="sans">Sans-serif</option>
<option value="serif">Serif</option>
@@ -567,7 +567,7 @@
</div>
<div class="theme-fd-group">
<label class="theme-fd-label">Density</label>
<select id="theme-density-select" class="theme-fd-select">
<select id="theme-density-select" class="theme-fd-select" aria-label="Density">
<option value="compact">Compact</option>
<option value="comfortable">Comfortable</option>
<option value="spacious">Spacious</option>
@@ -993,7 +993,7 @@
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
<div class="model-picker-menu hidden" id="model-picker-menu">
<div class="model-picker-search-row">
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off">
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off" aria-label="Search models">
<button type="button" class="model-picker-action-btn primary" id="model-picker-add-models-btn" title="Add model endpoints" aria-label="Add model endpoints">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
@@ -1007,7 +1007,7 @@
<div class="chat-input-left">
<!-- Overflow menu (+) — always first/left -->
<div class="overflow-wrapper">
<button type="button" class="input-icon-btn overflow-plus-btn" id="overflow-plus-btn" title="More tools">
<button type="button" class="input-icon-btn overflow-plus-btn" id="overflow-plus-btn" title="More tools" aria-label="More tools" aria-haspopup="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 15 12 9 18 15"/>
</svg>
@@ -1051,13 +1051,13 @@
</div>
</div>
<!-- Web search (magnifying glass) -->
<button type="button" class="input-icon-btn" title="Web search" id="web-toggle-btn" data-mode-tool="true">
<button type="button" class="input-icon-btn" title="Web search" id="web-toggle-btn" data-mode-tool="true" aria-label="Web search" aria-pressed="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
<!-- Shell commands (terminal) -->
<button type="button" class="input-icon-btn" title="Shell Access" id="bash-toggle-btn" data-mode-tool="true">
<button type="button" class="input-icon-btn" title="Shell Access" id="bash-toggle-btn" data-mode-tool="true" aria-label="Shell access" aria-pressed="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
@@ -1099,8 +1099,8 @@
<div class="chat-input-right">
<!-- Agent / Chat mode toggle -->
<div class="mode-toggle">
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn">Agent</button>
<button type="button" class="mode-toggle-btn" id="mode-chat-btn">Chat</button>
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn" aria-pressed="true">Agent</button>
<button type="button" class="mode-toggle-btn" id="mode-chat-btn" aria-pressed="false">Chat</button>
</div>
<button type="submit" form="chat-form" class="send-btn newchat-mode" data-mode="newchat" aria-label="New chat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span class="send-btn-label">+ New</span>

View File

@@ -964,6 +964,11 @@ import createResearchSynapse from './researchSynapse.js';
return;
}
// Mark the chat log busy while streaming so screen readers wait for the
// settled response instead of announcing every token. Cleared in finally.
const _chatLog = document.getElementById('chat-history');
if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -2702,6 +2707,9 @@ import createResearchSynapse from './researchSynapse.js';
}
} finally {
clearProcessingProbe();
// Streaming done — let screen readers announce the settled response.
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
// Always clean up research tracking regardless of background state
_researchingStreamIds.delete(streamSessionId);
if (_researchingStreamIds.size === 0) {