Skip to content

Commit adf1479

Browse files
committed
fix(a11y): honour reduced-motion on the thinking timer + name the send button
The live "Thinking..." elapsed timer drove itself with a self-perpetuating requestAnimationFrame that rewrote the header text ~60x/second for the whole thinking duration and ignored prefers-reduced-motion. Replace it with a matchMedia-gated setInterval (reduced-motion => 1Hz whole seconds; coarse pointer => 250ms; fine pointer => 100ms). The tick self-clears when the timer element is gone, and the explicit teardown sites clear the interval instead of cancelling an animation frame. No visual change at the default motion setting. The icon-only send/stop button keeps its accessible name in `title`, synced per mode (Send message / Stop generation / Queue message), but `title` alone is not reliably announced by iOS VoiceOver on a <button>, so a screen-reader user can miss "Stop generation" while a response streams. Mirror the live title into aria-label (which IS announced) with a cheap attribute-filtered MutationObserver, so the accessible name always tracks the action from a single source of truth. Adds tests/test_chat_a11y_js.py, which lifts the real timer and aria helpers out of chat.js and executes them under node with a stubbed matchMedia and MutationObserver, asserting the reduced-motion cadence and the mirrored name.
1 parent 28d27ee commit adf1479

2 files changed

Lines changed: 248 additions & 9 deletions

File tree

static/js/chat.js

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,32 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
362362
try { requestAnimationFrame(() => _wireArrowUpRecall(document.getElementById('message'))); } catch (_) {}
363363
setTimeout(() => _wireArrowUpRecall(document.getElementById('message')), 250);
364364
}
365+
366+
// Keep the send/stop button's ACCESSIBLE NAME tracking its action. The
367+
// button's `title` is already kept in sync per mode (Send message / Stop
368+
// generation / Queue message), but `title` alone is not reliably announced
369+
// by iOS VoiceOver on an icon-only button, so a screen-reader user can miss
370+
// "Stop generation" while a response streams. Mirror the live title into
371+
// aria-label (which IS announced) — same single source of truth (the
372+
// mode-synced title), just made reliable. One element, attribute-filtered,
373+
// so the observer is cheap and never loops (it sets a DIFFERENT attribute).
374+
const _wireSendBtnAria = (btn) => {
375+
if (!btn) return false;
376+
if (btn.dataset.ariaMirror) return true;
377+
btn.dataset.ariaMirror = '1';
378+
const _mirror = () => {
379+
const t = btn.getAttribute('title');
380+
if (t && btn.getAttribute('aria-label') !== t) btn.setAttribute('aria-label', t);
381+
};
382+
_mirror(); // seed from the initial title before any mode switch
383+
try {
384+
new MutationObserver(_mirror).observe(btn, { attributes: true, attributeFilter: ['title'] });
385+
} catch (_) { /* no observer — the seed above still names the button */ }
386+
return true;
387+
};
388+
if (!_wireSendBtnAria(document.querySelector('.send-btn'))) {
389+
setTimeout(() => _wireSendBtnAria(document.querySelector('.send-btn')), 250);
390+
}
365391
}
366392

367393
// addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
@@ -1771,7 +1797,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
17711797
// Force-close thinking if still open (model never output boundary)
17721798
if (isThinking) {
17731799
isThinking = false;
1774-
cancelAnimationFrame(_thinkTimerRAF);
1800+
clearInterval(_thinkTimerId); _thinkTimerId = 0;
17751801
var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
17761802
if (_elapsedDone) {
17771803
accumulated = accumulated.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
@@ -1982,16 +2008,30 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
19822008
_liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot');
19832009
_liveThinkTimerEl = thinkContent.querySelector('.live-think-timer');
19842010
_liveThinkToggle = thinkContent.querySelector('.live-think-toggle');
1985-
// Live timer
2011+
// Live timer. The previous implementation self-perpetuated a
2012+
// requestAnimationFrame that rewrote textContent ~60x/second for
2013+
// the entire thinking duration and ignored prefers-reduced-motion.
2014+
// Drive it from a matchMedia-gated setInterval instead: reduced
2015+
// motion shows whole seconds at 1Hz, a coarse pointer ticks at
2016+
// 250ms and a fine pointer at 100ms (a tenths readout cannot
2017+
// change faster than 100ms anyway). The tick self-clears when the
2018+
// timer element is gone, matching the old rAF's self-terminating
2019+
// behaviour so the short-thinking teardown path still stops it.
19862020
var _thinkTimerStart = Date.now();
1987-
var _thinkTimerRAF = 0;
2021+
var _thinkTimerRM = matchMedia('(prefers-reduced-motion: reduce)').matches;
2022+
var _thinkTimerCoarse = matchMedia('(pointer: coarse)').matches;
2023+
var _thinkTimerId = 0;
19882024
function _tickThinkTimer() {
1989-
if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
1990-
var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
2025+
if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) {
2026+
if (_thinkTimerId) { clearInterval(_thinkTimerId); _thinkTimerId = 0; }
2027+
return;
2028+
}
2029+
var _sec = (Date.now() - _thinkTimerStart) / 1000;
2030+
var s = _thinkTimerRM ? String(Math.floor(_sec)) : _sec.toFixed(1);
19912031
_liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount);
1992-
_thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
19932032
}
1994-
_thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
2033+
_tickThinkTimer();
2034+
_thinkTimerId = setInterval(_tickThinkTimer, _thinkTimerRM ? 1000 : (_thinkTimerCoarse ? 250 : 100));
19952035
// Whirlpool spinner
19962036
if (_liveThinkSpinnerSlot) {
19972037
var _wp = spinnerModule.createWhirlpool(12);
@@ -2053,7 +2093,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
20532093

20542094
// Thinking ended — smooth transition: update header, pause, then collapse
20552095
// Stop live timer and spinner
2056-
cancelAnimationFrame(_thinkTimerRAF);
2096+
clearInterval(_thinkTimerId); _thinkTimerId = 0;
20572097
var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
20582098
// Embed thinking time in the <think> tag for persistence on reload
20592099
if (elapsed) {
@@ -2474,7 +2514,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
24742514
// Force-close thinking if still open — tools are real content, not thinking
24752515
if (isThinking) {
24762516
isThinking = false;
2477-
cancelAnimationFrame(_thinkTimerRAF);
2517+
clearInterval(_thinkTimerId); _thinkTimerId = 0;
24782518
var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
24792519
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
24802520
if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : '';

tests/test_chat_a11y_js.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
r"""Accessibility contracts for the chat composer and the live "thinking" timer.
2+
3+
Two behaviours are pinned here:
4+
5+
1. The live "Thinking..." elapsed timer must honour ``prefers-reduced-motion``.
6+
It previously self-perpetuated a ``requestAnimationFrame`` loop that rewrote
7+
the header text ~60x/second for the whole thinking duration and ignored the
8+
user's motion preference. It now runs on a ``matchMedia``-gated
9+
``setInterval`` (reduced-motion -> 1Hz whole seconds; coarse pointer ->
10+
250ms; fine pointer -> 100ms) and never calls ``requestAnimationFrame``.
11+
12+
2. The icon-only send/stop button must expose an accessible name. Its ``title``
13+
is kept in sync per mode, but ``title`` alone is not reliably announced by
14+
screen readers on a ``<button>``, so the live title is mirrored into
15+
``aria-label``.
16+
17+
``chat.js`` pulls in browser globals and cannot be imported under node, so each
18+
test lifts the exact production source of the relevant helper out of
19+
``static/js/chat.js`` and executes it inside a stubbed harness. That runs the
20+
real code, not a reimplementation, and fails if the fix is silently reverted.
21+
"""
22+
23+
import json
24+
import shutil
25+
import subprocess
26+
import textwrap
27+
from pathlib import Path
28+
29+
import pytest
30+
31+
_REPO = Path(__file__).resolve().parent.parent
32+
_CHAT_JS = _REPO / "static" / "js" / "chat.js"
33+
34+
pytestmark = pytest.mark.skipif(
35+
shutil.which("node") is None, reason="node binary not on PATH"
36+
)
37+
38+
39+
def _chat_src() -> str:
40+
return _CHAT_JS.read_text(encoding="utf-8")
41+
42+
43+
def _slice_braced(src: str, anchor: str) -> str:
44+
"""Return ``anchor`` plus the balanced ``{ ... }`` block that follows it."""
45+
i = src.index(anchor)
46+
brace = src.index("{", i)
47+
depth = 0
48+
for j in range(brace, len(src)):
49+
c = src[j]
50+
if c == "{":
51+
depth += 1
52+
elif c == "}":
53+
depth -= 1
54+
if depth == 0:
55+
return src[i : j + 1]
56+
raise AssertionError(f"unbalanced block after {anchor!r}")
57+
58+
59+
def _slice_timer_setup(src: str) -> str:
60+
"""The live-timer setup: from the start marker through the setInterval call."""
61+
start = src.index("var _thinkTimerStart = Date.now();")
62+
end_kw = "_thinkTimerId = setInterval(_tickThinkTimer,"
63+
e = src.index(end_kw, start)
64+
e = src.index(";", e) + 1
65+
return src[start:e]
66+
67+
68+
def _run_node(script: str) -> dict:
69+
result = subprocess.run(
70+
["node", "--input-type=module", "-e", script],
71+
cwd=str(_REPO),
72+
capture_output=True,
73+
text=True,
74+
timeout=15,
75+
)
76+
assert result.returncode == 0, (
77+
f"node failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}"
78+
)
79+
return json.loads(result.stdout.splitlines()[-1])
80+
81+
82+
def _run_timer(reduced_motion: bool, coarse: bool = False) -> dict:
83+
src = _chat_src()
84+
fmt = _slice_braced(src, "function _formatThinkStats(seconds, tokenCount)")
85+
timer = _slice_timer_setup(src)
86+
harness = (
87+
textwrap.dedent(
88+
"""
89+
const RM = __RM__, COARSE = __COARSE__;
90+
let rafCalls = 0;
91+
const intervalDelays = [];
92+
const cleared = [];
93+
globalThis.requestAnimationFrame = () => { rafCalls++; return 1; };
94+
globalThis.cancelAnimationFrame = () => {};
95+
globalThis.setInterval = (fn, ms) => { intervalDelays.push(ms); return 7; };
96+
globalThis.clearInterval = (id) => { cleared.push(id); };
97+
globalThis.matchMedia = (q) => ({
98+
matches: q.includes('prefers-reduced-motion') ? RM
99+
: q.includes('pointer: coarse') ? COARSE
100+
: false,
101+
});
102+
let _liveThinkTokenCount = 0;
103+
const _liveThinkTimerEl = { isConnected: true, textContent: '' };
104+
__FMT__
105+
__TIMER__
106+
const text = _liveThinkTimerEl.textContent;
107+
// Once the timer element detaches, the next tick must stop its own
108+
// interval (the short-thinking teardown path relies on this).
109+
_liveThinkTimerEl.isConnected = false;
110+
_tickThinkTimer();
111+
console.log(JSON.stringify({ rafCalls, intervalDelays, text, cleared }));
112+
"""
113+
)
114+
.replace("__RM__", "true" if reduced_motion else "false")
115+
.replace("__COARSE__", "true" if coarse else "false")
116+
.replace("__FMT__", fmt)
117+
.replace("__TIMER__", timer)
118+
)
119+
return _run_node(harness)
120+
121+
122+
def _run_aria() -> dict:
123+
fn = _slice_braced(_chat_src(), "const _wireSendBtnAria = (btn) =>")
124+
harness = textwrap.dedent(
125+
"""
126+
let observerCb = null;
127+
globalThis.MutationObserver = class {
128+
constructor(cb) { observerCb = cb; }
129+
observe() {}
130+
};
131+
function makeBtn(title) {
132+
const attrs = { title };
133+
return {
134+
dataset: {},
135+
_attrs: attrs,
136+
getAttribute(k) { return k in attrs ? attrs[k] : null; },
137+
setAttribute(k, v) { attrs[k] = v; },
138+
};
139+
}
140+
__FN__
141+
const btn = makeBtn('Send message');
142+
const wired = _wireSendBtnAria(btn);
143+
const seeded = btn.getAttribute('aria-label');
144+
// A mode switch updates the title; the observer mirrors it to aria-label.
145+
btn._attrs.title = 'Stop generation';
146+
if (observerCb) observerCb();
147+
const afterSwitch = btn.getAttribute('aria-label');
148+
// Idempotent: wiring the same button again is a no-op that still succeeds.
149+
const rewired = _wireSendBtnAria(btn);
150+
// A missing button is handled without throwing.
151+
const nullBtn = _wireSendBtnAria(null);
152+
console.log(JSON.stringify({ wired, seeded, afterSwitch, rewired, nullBtn }));
153+
"""
154+
).replace("__FN__", fn)
155+
return _run_node(harness)
156+
157+
158+
def test_thinking_timer_uses_interval_not_raf():
159+
out = _run_timer(reduced_motion=False, coarse=False)
160+
assert out["rafCalls"] == 0 # the animation loop is gone
161+
assert out["intervalDelays"] == [100] # fine-pointer desktop cadence
162+
assert "." in out["text"] # tenths readout when motion is allowed
163+
164+
165+
def test_thinking_timer_honours_reduced_motion():
166+
out = _run_timer(reduced_motion=True)
167+
assert out["rafCalls"] == 0 # never animates under reduced-motion
168+
assert out["intervalDelays"] == [1000] # 1Hz whole-second cadence
169+
assert "." not in out["text"] # whole seconds, no sub-second churn
170+
assert out["text"].endswith("s")
171+
172+
173+
def test_thinking_timer_coarse_pointer_cadence():
174+
out = _run_timer(reduced_motion=False, coarse=True)
175+
assert out["intervalDelays"] == [250]
176+
assert out["rafCalls"] == 0
177+
178+
179+
def test_thinking_timer_tick_self_clears_when_detached():
180+
out = _run_timer(reduced_motion=True)
181+
# The interval id (7 from the stub) is cleared once the element detaches.
182+
assert 7 in out["cleared"]
183+
184+
185+
def test_send_button_exposes_accessible_name():
186+
out = _run_aria()
187+
assert out["wired"] is True
188+
assert out["seeded"] == "Send message" # aria-label seeded from title
189+
assert out["afterSwitch"] == "Stop generation" # observer keeps it in sync
190+
assert out["rewired"] is True # idempotent re-wire
191+
assert out["nullBtn"] is False # null-safe
192+
193+
194+
def test_chat_source_keeps_reduced_motion_guard():
195+
# Source-level guard so the fix cannot be silently reverted to the
196+
# requestAnimationFrame loop that ignored prefers-reduced-motion.
197+
src = _chat_src()
198+
assert "_thinkTimerRAF" not in src
199+
assert "matchMedia('(prefers-reduced-motion: reduce)')" in src

0 commit comments

Comments
 (0)