I was halfway through a keyboard pass on a small chat demo when the delayed reply finally woke up. The painted paragraph looked smooth, but the mutation log showed the assertive bubble rewriting itself on almost every chunk. I stopped typing and asked a blunt question: why does a calm bubble schedule a new announcement for every fragment? The send button still showed focus in the log, yet the next Tab target was a copy control that should not have existed yet.
That afternoon I was not hunting a model-quality bug, and I was not blaming the network before the accessibility tree. I was watching a state machine lie about what streaming means to someone who cannot see the caret. Have you ever trusted a spinner because the pixels moved, then learned the live region was screaming? I had trusted that spinner, and the repair was smaller than the embarrassment I felt afterward.
What looked finished and what sounded broken
The visual layer appended tokens immediately, which is the behavior most of us screenshot for a progress demo. The accessibility layer treated the whole assistant bubble as an assertive live region, so each chunk aborted the previous speech. A copy button mounted as soon as a fence opened, and that button stole a tab stop from the composer. The draft survived in memory, but focus had moved, so the next Enter missed the control I thought was active.
I wrote that mismatch down before I changed a single style rule or renamed a component. If you skip the note, you will polish the animation and ship the same announcement storm. Does your status text describe only the phase, or does it secretly duplicate the entire transcript? Mine duplicated the transcript word for word, and that duplication was the first lie I had to unwind.
A symptom table I refused to skip
I keep a four-column note beside the editor until the patch is boring enough to retest. Each row names the action, the pixels, the speech policy, and the wrong blame I reached for first. If a row has no speech column, I do not get to call the bug fixed.
- I submitted one prompt, saw smooth tokens, logged a rewrite on each chunk, and wrongly blamed a chatty model.
- I pressed Tab during the burst, landed inside the fence, found an unnamed copy button, and wrongly blamed the browser.
- I pressed Escape, watched the spinner vanish, found no phase change, and wrongly called cancel a purely visual problem.
- I waited through a cold start, then a flood, logged one assertive dump, and wrongly treated latency as only a spinner issue.
How I read the rows
The rows are a debugging technique, not a design manifesto you can paste into a ticket and walk away. I ask which column changed, which column stayed silent, and which column I used as a convenient villain. Have you closed a bug because the spinner matched the mock, while the speech column stayed empty? I have done that, and the table is what makes the empty column impossible to ignore.
Three listeners before any CSS change
I did not open layout tooling first, because this bug lived in focus order and in live-region mutations. A tiny logger on focusin, assistant mutations, and chunk arrival showed me which event actually fired first. Would you rather debate a transition curve, or count how many times the live region mutates each second? I counted those mutations in the log, and the rate was high enough to embarrass the demo.
<div id="log" role="region" aria-label="Debug log" tabindex="0"></div>
<script>
const log = document.querySelector("#log");
const note = (line) => {
const item = document.createElement("p");
item.textContent = new Date().toISOString() + " " + line;
log.appendChild(item);
};
document.addEventListener("focusin", (event) => {
note("focusin " + (event.target.id || event.target.tagName));
});
const bubble = document.querySelector("#assistant");
new MutationObserver(() => note("assistant mutated")).observe(bubble, {
childList: true,
characterData: true,
subtree: true
});
</script>
A repeatable local burst
I then forced chunks locally so I was not debugging the network and the widget at the same time. A timer is not a benchmark, and it is not a claim about any vendor's latency or hardware. It only makes the same state transitions repeatable on a laptop before I trust a socket. If your fake reader cannot trigger cancel, error, and completion, it is an animation toy rather than a test.
async function fakeChunks(text, push, signal) {
const parts = text.match(/\S+\s*|\s+/g) ?? [];
for (const part of parts) {
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, 40));
push(part);
}
}
You can serve the single file and poke the composer from the console without hunting for a mouse path. I use a local static server so focus behavior matches a real origin rather than a file URL quirk. The console lines set a prompt and submit, which is the same transition I want a later screen-reader pass to repeat.
python3 -m http.server 4173
document.querySelector("#prompt").value = "Explain focus in one short sentence.";
document.querySelector("#composer").requestSubmit();
Root cause, underneath the costume it wore
The root cause was an announcement policy tied directly to paint, plus controls born before the markdown fence closed. One assertive region was acting as transcript, status line, and error channel, so every role interrupted the others. Replacing the message node on each chunk also dropped focus toward the log whenever the element identity changed. I had enabled Send whenever the textarea was non-empty, even while a reply was still in flight.
A polite status region from an earlier pass would not have saved this demo by itself. If you pipe every token into that status node, you still restart speech, only with a politer interrupt. The costume was an accessible live region, and the cause was announcing the paint. Have you ever added a role and called the interaction finished? I had done exactly that, and the mutation count called my bluff.
You should separate what you paint for sighted progress from the phrases you enqueue for speech. You should defer any new button until the structure that contains it is complete and nameable. Clear pending speech when the phase becomes cancelled or failed, so a late phrase cannot land afterward. Ignore a chunk whose generation does not match the current id, which keeps a stale socket from rewriting the bubble.
Eager paint, quiet speech
Sighted readers can keep immediate token paint, because the moving caret is already their progress cue. Assistive technology needs a coalesced phrase or a phase update, not a restart on every syllable. I leave the transcript at aria-live="off" and put phase copy in one polite status region that already exists in tab order. Errors use a single assertive announcement only when the phase actually changes to error, not when a token arrives.
const Phase = {
idle: "idle",
waiting: "waiting",
streaming: "streaming",
cancelled: "cancelled",
error: "error",
done: "done"
};
function createBuffer(announce, delay = 700) {
let timer = 0;
let pending = "";
return {
push(chunk) {
pending += chunk;
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const phrase = pending.trim();
pending = "";
if (phrase) announce(phrase);
}, delay);
},
flush() {
window.clearTimeout(timer);
const phrase = pending.trim();
pending = "";
if (phrase) announce(phrase);
},
clear() {
window.clearTimeout(timer);
pending = "";
}
};
}
This buffer is a proposal you can paste into a single file, not a telemetry result from a production cluster. I announce Generating reply when the phase enters waiting, then coalesced phrases while the phase stays streaming. On done I flush once, then announce Reply complete only if that exact phrase was not just spoken. On cancel I clear the buffer before I write Reply stopped, so the delayed timer cannot speak over the stop.
<section aria-labelledby="thread-title">
<h2 id="thread-title">Conversation</h2>
<p id="stream-status" role="status" aria-live="polite">Idle</p>
<div id="transcript" aria-live="off"></div>
<form id="composer">
<label for="prompt">Message</label>
<textarea id="prompt"></textarea>
<button type="submit" id="send">Send</button>
<button type="button" id="stop" disabled>Stop</button>
</form>
</section>
Copy buttons wait until the fence closes, because an early button is a tab stop without a stable accessible name. I render open fences as plain text, and I mount the button only after the closing marks arrive or the stream completes. Is that slower for a mouse user who wants to copy a half-written sample right away? Yes, and that delay stays intentional until the control can be named honestly for every user.
Phase table I keep next to the file
| Phase | Send | Stop | Status text | Transcript live | Buffer |
|---|---|---|---|---|---|
| idle | enabled | disabled | Idle | off | empty |
| waiting | disabled | enabled | Generating reply | off | empty |
| streaming | disabled | enabled | Generating reply | off | coalescing |
| cancelled | enabled | disabled | Reply stopped | off | cleared |
| error | enabled | disabled | Reply failed. Retry when ready. | off | cleared |
| done | enabled | disabled | Reply complete | off | flushed once |
Focus stays on Stop when the user activated Stop, and it returns to the textarea only if they did not move elsewhere. I set a userMovedFocus flag from the same focusin logger, so a helpful focus call cannot yank them out of the debug log. Have you ever shipped a convenience focus move that felt like a keyboard trap during a test? I have shipped that move, and the flag is how I stopped repeating it on the next pass.
A remote stream, after the local matrix already fails honestly
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I still run the fake reader first, because a local timer is how I force the same chunk size on demand. When I want a real cadence, I point that same push function at MonkeyCode's free model access and free server option. I do not treat one cold start as a quota, a hardware figure, or a permanent latency promise.
This file does not measure quotas or hardware, so those claims stay out of the notes. The local path is for frontend developers who need a keyboard-operable reproduction before they open a socket. If that free server is unavailable, the demo still teaches the buffer, because the fake reader exercises every phase in the table. You should not lean on the remote option alone for a conformance report, a capacity plan, or any uptime claim.
The request shape belongs in the current product docs, so STREAM_URL stays a placeholder instead of a path I invented. I pass whatever response those docs currently describe, and the reader below only consumes a ReadableStream. If the docs change the payload, this UI code should not need a rewrite, because the parser stays outside the widget.
async function readStream(response, push, signal) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (!signal.aborted) {
const step = await reader.read();
if (step.done) break;
push(decoder.decode(step.value, { stream: true }));
}
}
This harness records interface transitions only, and it is not a service-level test of the host. If the local matrix already passes, that same remote option can feed readStream without you provisioning a machine first. Follow the current docs for authentication and payload shape, and keep the phase table even when the network looks quiet. I would not start there, because a flaky socket hides the announcement bug you can already force with a timer.
Checks I expect, not scores I borrowed
I have not attached pass rates, browser builds, or assistive-technology versions, because those belong to your run rather than to this prose. If a transition fails, write down the browser, the operating system, the assistive technology and version, and the exact phase change. That note is more useful than another screenshot of a spinner that never explains the speech. Run the rows in order, and stop when a row disagrees with the expected transition.
| Check | Action | Expected transition |
|---|---|---|
| Announcement storm | Submit one sentence with 40ms chunks | Status changes on phase boundaries, not on every token |
| Early copy control | Tab from Stop while a fence is open | Focus does not enter an unnamed copy button |
| Cancel race | Press Stop, then wait one second | No coalesced phrase lands after Reply stopped |
| Late chunk | Deliver a chunk after the phase is cancelled | Buffer stays clear and the transcript does not grow |
| Focus retention | Leave focus in the debug log until done | Focus remains in the log when the reply completes |
| Retry | Activate Retry from the error phase | Fresh status text, new phase, previous transcript kept |
When I would not use this buffer
A seven-hundred-millisecond coalesce hides token-level speech, so skip it when every fragment must be announced verbatim instead. Assertive errors still need their own moment, or a polite delay will bury them behind a phrase that was already pending. The buffer does not fix a missing label, a positive tabindex on a non-control, or a dialog that never manages focus. If you need a formal conformance statement, treat this file as a regression aid, not as the statement itself.
I also skip the remote stream when the lesson is parser correctness, because jitter disguises a fence bug I can force with a string. Do not store raw prompts in the debug log when those prompts contain private data, and do not ship the logger. The pattern assumes you own the client state machine, and it cannot repair a socket that never signals completion.
Would I use an assertive bubble again just to make a demo sound busier than it is? No, I would not, because busy speech is not the same thing as an understandable reply. Leave the paint eager, keep the speech quiet, and let the phase table tell you when the reply is actually done.
Top comments (0)