Add dialog accessibility semantics

Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.

- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
  Settings) now carry role="dialog" + an accessible name. They are dockable,
  tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
  aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
  aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
  restore focus to the triggering element on close and trap Tab within the
  dialog (they already moved focus in on open).

tests/test_dialog_aria.py pins the roles, labels, and focus management.
This commit is contained in:
Collin
2026-06-01 23:41:25 -04:00
committed by GitHub
parent 77611f0491
commit c90a7a19a5
3 changed files with 89 additions and 13 deletions

View File

@@ -242,7 +242,7 @@
</script> </script>
<!-- Memory Management Modal --> <!-- Memory Management Modal -->
<div id="memory-modal" class="modal hidden"> <div id="memory-modal" class="modal hidden">
<div class="modal-content memory-modal-content" style="background:var(--bg)"> <div class="modal-content memory-modal-content" role="dialog" aria-label="Brain" style="background:var(--bg)">
<div class="modal-header"> <div class="modal-header">
<h4><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:-2px;margin-right:6px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg>Brain</h4> <h4><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:-2px;margin-right:6px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg>Brain</h4>
<button class="close-btn" id="close-memory-modal" aria-label="Close memory modal"></button> <button class="close-btn" id="close-memory-modal" aria-label="Close memory modal"></button>
@@ -432,14 +432,14 @@
<!-- Theme Popup (floating panel) --> <!-- Theme Popup (floating panel) -->
<div id="theme-modal" class="modal hidden"> <div id="theme-modal" class="modal hidden">
<div id="theme-popup" class="modal-content admin-modal-content" style="background:var(--bg)"> <div id="theme-popup" class="modal-content admin-modal-content" role="dialog" aria-label="Theme" style="background:var(--bg)">
<div class="modal-header theme-popup-header" id="theme-popup-header"> <div class="modal-header theme-popup-header" id="theme-popup-header">
<h4><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:-2px;margin-right:6px"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/><circle cx="8" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="14" r="1.5" fill="currentColor"/><circle cx="9" cy="15" r="1.5" fill="currentColor"/></svg>Theme</h4> <h4><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:-2px;margin-right:6px"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/><circle cx="8" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="14" r="1.5" fill="currentColor"/><circle cx="9" cy="15" r="1.5" fill="currentColor"/></svg>Theme</h4>
<button type="button" class="theme-opacity-wrap theme-opacity-toggle hidden" id="theme-opacity-wrap" title="Fade this window to preview the page behind it" aria-pressed="false"> <button type="button" class="theme-opacity-wrap theme-opacity-toggle hidden" id="theme-opacity-wrap" title="Fade this window to preview the page behind it" aria-pressed="false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<span class="theme-opacity-label">Peek</span> <span class="theme-opacity-label">Peek</span>
</button> </button>
<button class="close-btn" id="close-theme-popup">&#x2716;</button> <button class="close-btn" id="close-theme-popup" aria-label="Close theme">&#x2716;</button>
</div> </div>
<!-- Theme tabs --> <!-- Theme tabs -->
<div class="admin-tabs" id="theme-tabs"> <div class="admin-tabs" id="theme-tabs">
@@ -1115,10 +1115,10 @@
<!-- Character (custom preset) modal --> <!-- Character (custom preset) modal -->
<div id="custom-preset-modal" class="modal hidden"> <div id="custom-preset-modal" class="modal hidden">
<div class="modal-content preset-modal-content" style="background:var(--bg)"> <div class="modal-content preset-modal-content" role="dialog" aria-label="Prompt" style="background:var(--bg)">
<div class="modal-header"> <div class="modal-header">
<h4><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:-2px;margin-right:6px"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>Prompt</h4> <h4><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:-2px;margin-right:6px"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>Prompt</h4>
<button class="close-btn" id="close-custom-preset"></button> <button class="close-btn" id="close-custom-preset" aria-label="Close prompt"></button>
</div> </div>
<div class="modal-body preset-modal-body"> <div class="modal-body preset-modal-body">
<div id="char-fields-wrap"> <div id="char-fields-wrap">
@@ -1262,7 +1262,7 @@
<!-- Rename Session Modal --> <!-- Rename Session Modal -->
<div id="rename-session-modal" class="modal hidden"> <div id="rename-session-modal" class="modal hidden">
<div class="modal-content" style="width: 400px;"> <div class="modal-content" role="dialog" aria-label="Rename session" style="width: 400px;">
<div class="modal-header"> <div class="modal-header">
<h4>Rename Session</h4> <h4>Rename Session</h4>
<button class="close-btn" id="close-rename-session" aria-label="Close rename session modal"></button> <button class="close-btn" id="close-rename-session" aria-label="Close rename session modal"></button>
@@ -1288,10 +1288,10 @@
<!-- Cookbook Modal --> <!-- Cookbook Modal -->
<div id="cookbook-modal" class="modal hidden"> <div id="cookbook-modal" class="modal hidden">
<div class="modal-content" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);"> <div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
<div class="modal-header"> <div class="modal-header">
<h4 style="margin:0;margin-right:auto"><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:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4> <h4 style="margin:0;margin-right:auto"><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:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
<button class="close-btn" id="close-cookbook-modal"></button> <button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook"></button>
</div> </div>
<div class="modal-body cookbook-body"></div> <div class="modal-body cookbook-body"></div>
</div> </div>
@@ -1299,14 +1299,14 @@
<!-- Settings Modal (all users) --> <!-- Settings Modal (all users) -->
<div id="settings-modal" class="modal hidden"> <div id="settings-modal" class="modal hidden">
<div class="modal-content settings-modal-content"> <div class="modal-content settings-modal-content" role="dialog" aria-label="Settings">
<div class="modal-header"> <div class="modal-header">
<h4><span style="vertical-align:-1px;margin-right:6px;font-size:15px">&#x2699;</span>Settings</h4> <h4><span style="vertical-align:-1px;margin-right:6px;font-size:15px">&#x2699;</span>Settings</h4>
<button type="button" class="theme-opacity-wrap theme-opacity-toggle hidden" id="settings-opacity-wrap" title="Fade this window to preview the page behind it" aria-pressed="false"> <button type="button" class="theme-opacity-wrap theme-opacity-toggle hidden" id="settings-opacity-wrap" title="Fade this window to preview the page behind it" aria-pressed="false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<span class="theme-opacity-label">Peek</span> <span class="theme-opacity-label">Peek</span>
</button> </button>
<button class="close-btn"></button> <button class="close-btn" aria-label="Close settings"></button>
</div> </div>
<div class="admin-toggle-sub" style="padding:0 12px 8px;opacity:0.6;font-size:11px;">Toggle on/off visibility of tools and modules across the interface.</div> <div class="admin-toggle-sub" style="padding:0 12px 8px;opacity:0.6;font-size:11px;">Toggle on/off visibility of tools and modules across the interface.</div>
<div class="settings-layout"> <div class="settings-layout">

View File

@@ -579,8 +579,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
overlay.id = 'styled-confirm-overlay'; overlay.id = 'styled-confirm-overlay';
overlay.className = 'modal'; overlay.className = 'modal';
overlay.innerHTML = overlay.innerHTML =
'<div class="modal-content styled-confirm-box">' + '<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="styled-confirm-title" aria-describedby="styled-confirm-msg">' +
'<div class="modal-header"><h4>Confirm</h4></div>' + '<div class="modal-header"><h4 id="styled-confirm-title">Confirm</h4></div>' +
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' + '<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
'<div class="modal-footer">' + '<div class="modal-footer">' +
'<button id="styled-confirm-cancel"></button>' + '<button id="styled-confirm-cancel"></button>' +
@@ -600,6 +600,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary'; okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
cancelBtn.className = 'confirm-btn confirm-btn-secondary'; cancelBtn.className = 'confirm-btn confirm-btn-secondary';
// Remember what had focus so we can restore it when the dialog closes.
const _prevFocus = document.activeElement;
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
overlay.style.display = ''; overlay.style.display = '';
@@ -610,6 +612,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
cancelBtn.removeEventListener('click', onCancel); cancelBtn.removeEventListener('click', onCancel);
overlay.removeEventListener('click', onBackdrop); overlay.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
resolve(result); resolve(result);
} }
function onOk() { cleanup(true); } function onOk() { cleanup(true); }
@@ -626,6 +629,13 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
cleanup(false); cleanup(false);
} else if (e.key === 'Tab') {
// Trap focus inside the dialog so Tab can't wander to the page behind.
e.preventDefault();
const f = [cancelBtn, okBtn];
const i = f.indexOf(document.activeElement);
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
f[n].focus();
} }
} }
@@ -656,7 +666,7 @@ export function styledPrompt(message, {
overlay.id = 'styled-prompt-overlay'; overlay.id = 'styled-prompt-overlay';
overlay.className = 'modal'; overlay.className = 'modal';
overlay.innerHTML = overlay.innerHTML =
'<div class="modal-content styled-confirm-box styled-prompt-box">' + '<div class="modal-content styled-confirm-box styled-prompt-box" role="dialog" aria-modal="true" aria-labelledby="styled-prompt-title" aria-describedby="styled-prompt-msg">' +
'<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' + '<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' +
'<div class="modal-body">' + '<div class="modal-body">' +
'<p id="styled-prompt-msg"></p>' + '<p id="styled-prompt-msg"></p>' +
@@ -685,6 +695,8 @@ export function styledPrompt(message, {
okBtn.textContent = confirmText; okBtn.textContent = confirmText;
cancelBtn.textContent = cancelText; cancelBtn.textContent = cancelText;
// Remember what had focus so we can restore it when the dialog closes.
const _prevFocus = document.activeElement;
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
overlay.style.display = ''; overlay.style.display = '';
@@ -696,6 +708,7 @@ export function styledPrompt(message, {
overlay.removeEventListener('click', onBackdrop); overlay.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
input.removeEventListener('keydown', onInputKey); input.removeEventListener('keydown', onInputKey);
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
resolve(result); resolve(result);
} }
function onOk() { cleanup((input.value || '').trim()); } function onOk() { cleanup((input.value || '').trim()); }
@@ -707,6 +720,13 @@ export function styledPrompt(message, {
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
cleanup(null); cleanup(null);
} else if (e.key === 'Tab') {
// Trap focus inside the dialog (input → Cancel → OK → input …).
e.preventDefault();
const f = [input, cancelBtn, okBtn];
const i = f.indexOf(document.activeElement);
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
f[n].focus();
} }
} }
function onInputKey(e) { function onInputKey(e) {

56
tests/test_dialog_aria.py Normal file
View File

@@ -0,0 +1,56 @@
"""Pin the dialog accessibility semantics added for the roadmap a11y pass.
Screen readers only announce "dialog" (and its name) when the container
carries role="dialog" plus an accessible name. These checks lock that in for
the static modals in index.html and the JS-built confirm/prompt dialogs, and
guard against a close button shipping without an accessible label again.
Plain text/regex assertions (no bs4 dependency), matching the lightweight style
of the other tests in this suite.
"""
import re
from pathlib import Path
_REPO = Path(__file__).resolve().parent.parent
_INDEX = (_REPO / "static" / "index.html").read_text(encoding="utf-8")
_UI = (_REPO / "static" / "js" / "ui.js").read_text(encoding="utf-8")
def test_static_modals_expose_dialog_role_and_name():
# Each static tool window must announce itself as a named dialog. These are
# dockable/tiling windows, so they are role="dialog" WITHOUT aria-modal.
for name in ("Brain", "Theme", "Prompt", "Rename session", "Cookbook", "Settings"):
assert f'role="dialog" aria-label="{name}"' in _INDEX, f"missing dialog role/name for {name!r}"
def test_no_modal_close_button_is_unlabeled():
# Every .close-btn must carry an accessible name (text glyph alone reads as
# "heavy multiplication x"). Catch any new close button that forgets one.
buttons = re.findall(r'<button[^>]*class="close-btn"[^>]*>', _INDEX)
assert buttons, "expected to find close-btn buttons in index.html"
unlabeled = [b for b in buttons if "aria-label=" not in b]
assert not unlabeled, f"close buttons missing aria-label: {unlabeled}"
def test_styled_confirm_and_prompt_are_modal_dialogs():
# The JS-built confirm/prompt overlays ARE blocking modals, so they get
# role="dialog" + aria-modal="true" and are labelled by their title.
assert 'class="modal-content styled-confirm-box" role="dialog" aria-modal="true"' in _UI
assert 'aria-labelledby="styled-confirm-title"' in _UI
assert '<h4 id="styled-confirm-title">Confirm</h4>' in _UI
assert 'styled-prompt-box" role="dialog" aria-modal="true"' in _UI
assert 'aria-labelledby="styled-prompt-title"' in _UI
# The label/description targets the styled-prompt dialog points at must exist.
assert 'id="styled-prompt-title"' in _UI
assert 'id="styled-prompt-msg"' in _UI
def test_styled_dialogs_manage_focus():
# A dialog is only really accessible if it restores focus to the trigger on
# close and traps Tab while open. Both styledConfirm and styledPrompt should
# capture the previously-focused element, restore it, and trap Tab.
assert _UI.count("const _prevFocus = document.activeElement;") == 2
assert _UI.count("_prevFocus && _prevFocus.focus && _prevFocus.focus()") == 2
assert _UI.count("e.key === 'Tab'") == 2