Skip to content

fix(a11y): honour reduced-motion on the thinking timer + name the send button#5589

Open
BrunooMoniz wants to merge 1 commit into
odysseus-dev:devfrom
BrunooMoniz:fix/mobile-a11y-micro
Open

fix(a11y): honour reduced-motion on the thinking timer + name the send button#5589
BrunooMoniz wants to merge 1 commit into
odysseus-dev:devfrom
BrunooMoniz:fix/mobile-a11y-micro

Conversation

@BrunooMoniz

Copy link
Copy Markdown
Contributor

Summary

Two small, related accessibility fixes in the chat UI, both in static/js/chat.js. First, the live "Thinking…" elapsed timer drove itself with a self-perpetuating requestAnimationFrame that rewrote the header text roughly 60 times a second — a tenths-of-a-second readout — for the entire thinking duration, and it ignored prefers-reduced-motion, so a user who asked for reduced motion still got a rapidly repainting counter. This PR replaces that loop with a matchMedia-gated setInterval: under reduced motion the timer shows whole seconds at 1Hz, a coarse pointer ticks at 250ms, and a fine pointer at 100ms (a tenths readout can't change faster than 100ms anyway). The tick self-clears once the timer element is gone, matching the old loop's self-terminating behaviour, and the three explicit teardown sites clear the interval instead of cancelling an animation frame. At the default motion setting the readout is visually identical. Second, the icon-only send/stop button keeps its accessible name only 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. The fix mirrors the live title into aria-label (which is announced) with a cheap attribute-filtered MutationObserver, from the same single source of truth, seeding the label once at wire time so it is correct before any mode switch.

Target branch

  • This PR targets dev, not main. All PRs land in dev; main is curated by the maintainer at each release. If your PR is on main by accident, click "Edit" on this PR and change the base.

Linked Issue

Fixes #5588

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)
  • New feature (non-breaking — adds new behaviour)
  • Breaking change (changes or removes existing behaviour)
  • Refactor / cleanup (behaviour unchanged)
  • Documentation only
  • CI / tooling / configuration

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev
  • My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
  • I actually ran the app (docker compose up or uvicorn app:app) and verified the change works end-to-end. Type-checks and unit tests are not enough.

How to Test

Steps a reviewer can reproduce (full app):

A. Reduced-motion on the thinking timer

  1. docker compose up -d --build, open the chat.
  2. Enable reduced motion. In DevTools: open the Command Menu, run "Show Rendering", set "Emulate CSS media feature prefers-reduced-motion" to reduce (or set it at the OS level).
  3. Send a prompt to a model that streams a thinking/reasoning block and watch the "Thinking…" header.
  4. On current dev: the timer repaints a tenths counter many times a second regardless of the preference. With this branch: under reduced motion it updates once a second in whole seconds; with motion allowed it is unchanged.

B. Accessible name on the send/stop button

  1. Open the chat, then open DevTools > Elements > Accessibility pane (or run a screen reader).
  2. Select the round send button next to the composer and read its computed accessible name while idle.
  3. Start a response so the button becomes the stop button, and read the name again.
  4. On current dev: there is no aria-label, so the name is empty / unlabelled. With this branch: the name reads "Send message" when idle and "Stop generation" while streaming, and tracks the button's title as the mode changes.

What was verified for this change:

  • Both behaviours confirmed in a running instance (reduced-motion emulated via the DevTools Rendering panel; accessible name read from the DevTools accessibility tree, idle and mid-stream).
  • node --check static/js/chat.js — passes.
  • python -m pytest tests/test_chat_a11y_js.py — 6 passed (reduced-motion cadence, fine/coarse-pointer cadence, no requestAnimationFrame, self-clearing tick, and the mirrored accessible name).
  • python -m pytest — full suite green: 4625 passed, 4 skipped, 0 failed (baseline on this dev checkout without the change: 4619 passed, 4 skipped — i.e. +6 from the new test, no regressions).

Files changed:

  • static/js/chat.js — the live thinking timer now runs on a matchMedia-gated setInterval instead of a requestAnimationFrame loop (three teardown sites switch to clearInterval), and init() mirrors the send button's title into aria-label via an attribute-filtered MutationObserver.
  • tests/test_chat_a11y_js.py — new. chat.js pulls in browser globals and can't be imported under node, so the test lifts the real timer and aria helpers out of the source and runs them under node with a stubbed matchMedia / MutationObserver, asserting the reduced-motion cadence and the mirrored accessible name.

Visual / UI changes — REQUIRED if you touched anything that renders

Anything that changes what the UI looks like — buttons, icons, padding, colors, fonts, spacing, layout, CSS, HTML, SVG, or any static/js/ module that draws to the DOM — needs all of the following. PRs that change rendering without these WILL be closed.

  • Screenshot or short clip of the change in the running app, attached below. Mobile screenshot too if the change affects mobile.
    • No visual change: this PR is behavioural / accessibility-only. At the default motion setting the thinking timer renders exactly as before (same tenths readout), and the aria-label mirror is screen-reader-only. There is nothing to screenshot — see How to Test for reproduction with reduced-motion emulated and with the DevTools accessibility tree, and the node-driven test for an automated proof. No colours, fonts, spacing, components, or markup were added or changed.
  • Style match: the change uses Odysseus's existing visual language. Specifically:
    • Reuse existing CSS variables (--red, --fg, --bg, --card, --border, etc.) — do not introduce new color values, font sizes, or spacing units.
    • Reuse existing button/input/card/border classes. Don't invent parallel styling.
    • No Unicode emoji in UI or code. Use inline SVG (matching the monochrome icon style already in static/index.html) or plain text.
    • Monospaced font (Fira Code) for primary UI text. Don't override.
    • Dark theme is the default; any light-mode work must be wired through the existing theme system, not hard-coded.
    • (No styling was touched at all — no CSS/colour/font/spacing/markup changes.)
  • No new component patterns. If a similar widget already exists in the app, extend it instead of writing a parallel one.
  • I am not an LLM agent submitting a bulk PR. If you are, please open an issue describing the problem first — bulk auto-generated PRs that don't match the project's visual style are closed on sight, even when the underlying fix is correct.

Screenshots / clips

No screenshots — the change is non-visual (behavioural reduced-motion timing and a screen-reader-only aria-label). Reproduction and an automated node-driven test are described under How to Test above.

…d 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.
@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Thinking timer ignores reduced-motion; send button lacks an accessible name

1 participant