Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions static/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,32 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
try { requestAnimationFrame(() => _wireArrowUpRecall(document.getElementById('message'))); } catch (_) {}
setTimeout(() => _wireArrowUpRecall(document.getElementById('message')), 250);
}

// Keep the send/stop button's ACCESSIBLE NAME tracking its action. The
// button's `title` is already kept in sync per mode (Send message / Stop
// generation / Queue message), but `title` alone is not reliably announced
// by iOS VoiceOver on an icon-only button, so a screen-reader user can miss
// "Stop generation" while a response streams. Mirror the live title into
// aria-label (which IS announced) — same single source of truth (the
// mode-synced title), just made reliable. One element, attribute-filtered,
// so the observer is cheap and never loops (it sets a DIFFERENT attribute).
const _wireSendBtnAria = (btn) => {
if (!btn) return false;
if (btn.dataset.ariaMirror) return true;
btn.dataset.ariaMirror = '1';
const _mirror = () => {
const t = btn.getAttribute('title');
if (t && btn.getAttribute('aria-label') !== t) btn.setAttribute('aria-label', t);
};
_mirror(); // seed from the initial title before any mode switch
try {
new MutationObserver(_mirror).observe(btn, { attributes: true, attributeFilter: ['title'] });
} catch (_) { /* no observer — the seed above still names the button */ }
return true;
};
if (!_wireSendBtnAria(document.querySelector('.send-btn'))) {
setTimeout(() => _wireSendBtnAria(document.querySelector('.send-btn')), 250);
}
}

// addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
Expand Down Expand Up @@ -1771,7 +1797,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Force-close thinking if still open (model never output boundary)
if (isThinking) {
isThinking = false;
cancelAnimationFrame(_thinkTimerRAF);
clearInterval(_thinkTimerId); _thinkTimerId = 0;
var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
if (_elapsedDone) {
accumulated = accumulated.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
Expand Down Expand Up @@ -1982,16 +2008,30 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot');
_liveThinkTimerEl = thinkContent.querySelector('.live-think-timer');
_liveThinkToggle = thinkContent.querySelector('.live-think-toggle');
// Live timer
// Live timer. The previous implementation self-perpetuated a
// requestAnimationFrame that rewrote textContent ~60x/second for
// the entire thinking duration and ignored prefers-reduced-motion.
// Drive it from a matchMedia-gated setInterval instead: reduced
// motion shows whole seconds at 1Hz, a coarse pointer ticks at
// 250ms and a fine pointer at 100ms (a tenths readout cannot
// change faster than 100ms anyway). The tick self-clears when the
// timer element is gone, matching the old rAF's self-terminating
// behaviour so the short-thinking teardown path still stops it.
var _thinkTimerStart = Date.now();
var _thinkTimerRAF = 0;
var _thinkTimerRM = matchMedia('(prefers-reduced-motion: reduce)').matches;
var _thinkTimerCoarse = matchMedia('(pointer: coarse)').matches;
var _thinkTimerId = 0;
function _tickThinkTimer() {
if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) {
if (_thinkTimerId) { clearInterval(_thinkTimerId); _thinkTimerId = 0; }
return;
}
var _sec = (Date.now() - _thinkTimerStart) / 1000;
var s = _thinkTimerRM ? String(Math.floor(_sec)) : _sec.toFixed(1);
_liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount);
_thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
}
_thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
_tickThinkTimer();
_thinkTimerId = setInterval(_tickThinkTimer, _thinkTimerRM ? 1000 : (_thinkTimerCoarse ? 250 : 100));
// Whirlpool spinner
if (_liveThinkSpinnerSlot) {
var _wp = spinnerModule.createWhirlpool(12);
Expand Down Expand Up @@ -2053,7 +2093,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer

// Thinking ended — smooth transition: update header, pause, then collapse
// Stop live timer and spinner
cancelAnimationFrame(_thinkTimerRAF);
clearInterval(_thinkTimerId); _thinkTimerId = 0;
var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
// Embed thinking time in the <think> tag for persistence on reload
if (elapsed) {
Expand Down Expand Up @@ -2474,7 +2514,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Force-close thinking if still open — tools are real content, not thinking
if (isThinking) {
isThinking = false;
cancelAnimationFrame(_thinkTimerRAF);
clearInterval(_thinkTimerId); _thinkTimerId = 0;
var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : '';
Expand Down
199 changes: 199 additions & 0 deletions tests/test_chat_a11y_js.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
r"""Accessibility contracts for the chat composer and the live "thinking" timer.

Two behaviours are pinned here:

1. The live "Thinking..." elapsed timer must honour ``prefers-reduced-motion``.
It previously self-perpetuated a ``requestAnimationFrame`` loop that rewrote
the header text ~60x/second for the whole thinking duration and ignored the
user's motion preference. It now runs on a ``matchMedia``-gated
``setInterval`` (reduced-motion -> 1Hz whole seconds; coarse pointer ->
250ms; fine pointer -> 100ms) and never calls ``requestAnimationFrame``.

2. The icon-only send/stop button must expose an accessible name. Its ``title``
is kept in sync per mode, but ``title`` alone is not reliably announced by
screen readers on a ``<button>``, so the live title is mirrored into
``aria-label``.

``chat.js`` pulls in browser globals and cannot be imported under node, so each
test lifts the exact production source of the relevant helper out of
``static/js/chat.js`` and executes it inside a stubbed harness. That runs the
real code, not a reimplementation, and fails if the fix is silently reverted.
"""

import json
import shutil
import subprocess
import textwrap
from pathlib import Path

import pytest

_REPO = Path(__file__).resolve().parent.parent
_CHAT_JS = _REPO / "static" / "js" / "chat.js"

pytestmark = pytest.mark.skipif(
shutil.which("node") is None, reason="node binary not on PATH"
)


def _chat_src() -> str:
return _CHAT_JS.read_text(encoding="utf-8")


def _slice_braced(src: str, anchor: str) -> str:
"""Return ``anchor`` plus the balanced ``{ ... }`` block that follows it."""
i = src.index(anchor)
brace = src.index("{", i)
depth = 0
for j in range(brace, len(src)):
c = src[j]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return src[i : j + 1]
raise AssertionError(f"unbalanced block after {anchor!r}")


def _slice_timer_setup(src: str) -> str:
"""The live-timer setup: from the start marker through the setInterval call."""
start = src.index("var _thinkTimerStart = Date.now();")
end_kw = "_thinkTimerId = setInterval(_tickThinkTimer,"
e = src.index(end_kw, start)
e = src.index(";", e) + 1
return src[start:e]


def _run_node(script: str) -> dict:
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=str(_REPO),
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0, (
f"node failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}"
)
return json.loads(result.stdout.splitlines()[-1])


def _run_timer(reduced_motion: bool, coarse: bool = False) -> dict:
src = _chat_src()
fmt = _slice_braced(src, "function _formatThinkStats(seconds, tokenCount)")
timer = _slice_timer_setup(src)
harness = (
textwrap.dedent(
"""
const RM = __RM__, COARSE = __COARSE__;
let rafCalls = 0;
const intervalDelays = [];
const cleared = [];
globalThis.requestAnimationFrame = () => { rafCalls++; return 1; };
globalThis.cancelAnimationFrame = () => {};
globalThis.setInterval = (fn, ms) => { intervalDelays.push(ms); return 7; };
globalThis.clearInterval = (id) => { cleared.push(id); };
globalThis.matchMedia = (q) => ({
matches: q.includes('prefers-reduced-motion') ? RM
: q.includes('pointer: coarse') ? COARSE
: false,
});
let _liveThinkTokenCount = 0;
const _liveThinkTimerEl = { isConnected: true, textContent: '' };
__FMT__
__TIMER__
const text = _liveThinkTimerEl.textContent;
// Once the timer element detaches, the next tick must stop its own
// interval (the short-thinking teardown path relies on this).
_liveThinkTimerEl.isConnected = false;
_tickThinkTimer();
console.log(JSON.stringify({ rafCalls, intervalDelays, text, cleared }));
"""
)
.replace("__RM__", "true" if reduced_motion else "false")
.replace("__COARSE__", "true" if coarse else "false")
.replace("__FMT__", fmt)
.replace("__TIMER__", timer)
)
return _run_node(harness)


def _run_aria() -> dict:
fn = _slice_braced(_chat_src(), "const _wireSendBtnAria = (btn) =>")
harness = textwrap.dedent(
"""
let observerCb = null;
globalThis.MutationObserver = class {
constructor(cb) { observerCb = cb; }
observe() {}
};
function makeBtn(title) {
const attrs = { title };
return {
dataset: {},
_attrs: attrs,
getAttribute(k) { return k in attrs ? attrs[k] : null; },
setAttribute(k, v) { attrs[k] = v; },
};
}
__FN__
const btn = makeBtn('Send message');
const wired = _wireSendBtnAria(btn);
const seeded = btn.getAttribute('aria-label');
// A mode switch updates the title; the observer mirrors it to aria-label.
btn._attrs.title = 'Stop generation';
if (observerCb) observerCb();
const afterSwitch = btn.getAttribute('aria-label');
// Idempotent: wiring the same button again is a no-op that still succeeds.
const rewired = _wireSendBtnAria(btn);
// A missing button is handled without throwing.
const nullBtn = _wireSendBtnAria(null);
console.log(JSON.stringify({ wired, seeded, afterSwitch, rewired, nullBtn }));
"""
).replace("__FN__", fn)
return _run_node(harness)


def test_thinking_timer_uses_interval_not_raf():
out = _run_timer(reduced_motion=False, coarse=False)
assert out["rafCalls"] == 0 # the animation loop is gone
assert out["intervalDelays"] == [100] # fine-pointer desktop cadence
assert "." in out["text"] # tenths readout when motion is allowed


def test_thinking_timer_honours_reduced_motion():
out = _run_timer(reduced_motion=True)
assert out["rafCalls"] == 0 # never animates under reduced-motion
assert out["intervalDelays"] == [1000] # 1Hz whole-second cadence
assert "." not in out["text"] # whole seconds, no sub-second churn
assert out["text"].endswith("s")


def test_thinking_timer_coarse_pointer_cadence():
out = _run_timer(reduced_motion=False, coarse=True)
assert out["intervalDelays"] == [250]
assert out["rafCalls"] == 0


def test_thinking_timer_tick_self_clears_when_detached():
out = _run_timer(reduced_motion=True)
# The interval id (7 from the stub) is cleared once the element detaches.
assert 7 in out["cleared"]


def test_send_button_exposes_accessible_name():
out = _run_aria()
assert out["wired"] is True
assert out["seeded"] == "Send message" # aria-label seeded from title
assert out["afterSwitch"] == "Stop generation" # observer keeps it in sync
assert out["rewired"] is True # idempotent re-wire
assert out["nullBtn"] is False # null-safe


def test_chat_source_keeps_reduced_motion_guard():
# Source-level guard so the fix cannot be silently reverted to the
# requestAnimationFrame loop that ignored prefers-reduced-motion.
src = _chat_src()
assert "_thinkTimerRAF" not in src
assert "matchMedia('(prefers-reduced-motion: reduce)')" in src