Skip to content

fix(widget): harden voice after independent review - #155

Merged
PAMulligan merged 3 commits into
mainfrom
54-voice-review-fixes
Sep 19, 2026
Merged

PAMulligan merged 3 commits into
mainfrom
54-voice-review-fixes

Conversation

@PAMulligan

Copy link
Copy Markdown
Contributor

Description

Follow-up to #153, which was merged before an independent review of it had finished. The review reproduced real bugs in the voice feature that are now on main. This PR fixes them. Please merge it before the 1.16.0 release PR (#154), so voice never ships in the broken state. Nothing has been released yet: no tag contains the voice commits.

Why this matters even for sites that never enable voice

speechSynthesis is a page-wide global, and the read-aloud hook mounts for every chat window whether voice is on or off. Its cleanup called speechSynthesis.cancel() unconditionally, so closing the chat cancels the host page's own speech on every existing embed. Only sites that use speech synthesis themselves would notice, but it breaks the promise that voice is "off by default" with no behaviour change. The reader now only ever cancels speech it started.

What else the review found, all fixed here

Each fix is pinned by a regression test (widget suite 507 → 541).

  • Pause was broken in Chrome and Safari. They set speechSynthesis.paused only once the engine confirms, so reading it right after pause() left the UI on "Pause" with no way to resume. 507 green tests missed it because my test double set the flag synchronously, encoding my own wrong assumption. I confirmed the real behaviour against Chrome 153. State now follows the utterance's pause / resume events, and the fake confirms a pause asynchronously by default.
  • Two widgets on one page silenced each other. One's late interrupted error cancelled the other's fresh queue, so nothing played.
  • Hold mode did not last as long as the hold. The engine ends a session at the first pause in speech, which silently dropped whatever came next and, with auto-submit, sent half a message with the button still down. A hold now spans sessions, and auto-submit waits for the release. Toggle starts, including the assistive-technology click, are deliberately not bridged, since nothing guarantees their release.
  • Re-pressing quickly duplicated the text ("hello world hello world"), and auto-submit would send it.
  • Read-aloud could start with the mic open, so auto-submit would send the bot's own sentence as the visitor's message. Dictation and read-aloud now end each other in both directions.
  • Keyboard focus fell to <body> when the read-aloud controls swapped, which also let Tab escape the dialog's focus trap.
  • voice: "false" as a string switched the microphone on. The option now fails closed: only true or an options object enables it.
  • Smaller: 560 characters of Japanese became a single utterance, straight into Chrome's 15 s cutoff (CJK sentence marks are now recognized, and the chunk limit is about ten seconds of speech); a URL swallowed the period after it, merging two sentences when read aloud; stopping while paused left the engine paused, so the next reply queued in silence; auto-submit during a loading request cleared text it could not send; an error without a following end left the mic button stuck on; the focus ring was invisible on the accent-filled mic.

Docs and the design doc are updated to match, notably the hold-mode section of configuration/voice.md, which claimed "recording runs from press to release" when it did not.

Left for later, deliberately

  • A tap-to-toggle hybrid for hold mode, which would help switch-device and keyboard users who cannot sustain a press. Hold mode is opt-in and toggle is the default.
  • A watchdog for an Android engine that emits nothing at all on pause(). The end and error variants are handled; in the silent case the visitor can still press Stop.

Related Issue

Refs #54 (closed by #153)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Chore (dependency updates, CI changes, refactoring)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist

  • Tests pass (pnpm test in both widget/ and worker/): widget 541, worker 153, scripts 82
  • Linting passes (pnpm lint in widget/): 0 errors, the same 2 pre-existing warnings as main
  • Formatting passes (pnpm format:check in widget/)
  • Types check (pnpm typecheck in widget/), and docs:api:check is clean
  • Widget builds successfully (cd widget && pnpm build)
  • Documentation updated, and the docs site built locally (61 pages)
  • Branch name follows convention (feat/, fix/, docs/, chore/): uses the repo's existing <issue>-<slug> pattern

Screenshots / Notes

Bundle size. The fixes add about 0.4 KB gzip to the embed bundle. That fits inside the headroom of the budgets raised in #153, so .size-limit.json is unchanged. Voice now totals +4.30 KB gzip on claudius.iife.js against the pre-voice main.

Verification. All 15 desktop e2e tests pass locally against system Chrome 153. A new one drives hold-to-talk through the built IIFE bundle with a real mouse.down() / mouse.up(). That confirms in Chromium that the click fired after a release carries a click count and so does not restart dictation, which was the one assumption the reviewer could not settle by reading the code.

Still not verified, as in #153: real speech end to end (this machine has no microphone or TTS voices) and physical phones. One specific thing to try on an iPhone is hold mode: under the HTML spec a touch pointerdown is not a user-activation event, so if WebKit gates SpeechRecognition.start() on a user gesture, hold-to-talk could fail there with "Microphone access is blocked" even though toggle mode works. Only a device can tell.

🤖 Generated with Claude Code

PAMulligan and others added 2 commits September 18, 2026 22:47
An independent review of the voice feature reproduced several real bugs
that 507 green tests had missed, largely because the speechSynthesis test
double encoded my own wrong assumption. Each fix below is pinned by a
regression test (507 -> 541).

Read aloud
- Pause state now follows the utterance's pause / resume events. Chrome
  and Safari set `speechSynthesis.paused` only once the engine confirms,
  so reading the flag right after pause() left the UI on "Pause" with no
  way to resume. Confirmed against Chrome 153. The fake now confirms a
  pause asynchronously by default, and no longer clears `paused` on
  cancel(), which the spec says it must not.
- The reader only cancels speech it started. `speechSynthesis` is a page
  global and the hook mounts even with voice off, so closing the chat
  silenced the host page's own speech on every embed. With two widgets,
  the first one's late "interrupted" error cancelled the second's queue
  and nothing played.
- Stopping while paused resumes the engine, which cancel() leaves paused;
  otherwise the next reply queued in silence.
- The controls keep keyboard focus. Swapping a bare button for a wrapper
  dropped focus to <body>, outside the dialog's focus trap, so Tab could
  leave the dialog.

Dictation
- Hold mode lasts as long as the hold. With single-utterance sessions the
  engine ends at the first pause, silently dropping the rest and, with
  auto-submit, sending half a message while the button was still down. A
  hold now spans sessions and auto-submit waits for the release. Toggle
  starts, including the assistive-technology click, are not bridged,
  since nothing guarantees their release.
- Pressing again while the previous session winds down no longer
  duplicates the text: a refused start() must not move the dictation base.
- Read-aloud now ends dictation, as dictation already ended read-aloud.
  Otherwise the mic transcribed the reply and auto-submit sent it as the
  visitor's message.
- A request starting to load ends dictation, so auto-submit cannot clear
  text that useChat then refuses to send.
- An error releases the session without waiting for `end`; an engine-side
  "aborted" no longer runs auto-submit; a voice error clears on send.

Helpers
- chunkSpeechText: 150 characters (about ten seconds, with room for
  numbers), CJK sentence marks, and a last-resort split for unbroken
  tokens. 560 characters of Japanese used to become one utterance.
- stripAnnouncementFormatting keeps the punctuation after a URL, which
  now decides where speech pauses.
- The `voice` option fails closed: the string "false" no longer switches
  the microphone on.
- Visible focus ring on the mic while it is filled with the accent color.

e2e: fakes behave like Chrome, plus hold-to-talk through the IIFE bundle
with a real mouse press and release, confirming in Chromium that the
click fired after the release carries a click count and does not restart
dictation.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hold mode now really does run from press to release, with auto-submit on
release; say so, and explain why. Note that dictation and read-aloud end
each other, that the widget only stops speech it started (relevant to
sites that use speechSynthesis themselves), that the option fails closed,
and that CJK sentence marks are recognized. The design doc records the
reasoning behind each change, including the pause-state bug the first
test double had hidden.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
claudius.iife.js (raw) 206.68 KB (+0.69% 🔺)
claudius.iife.js (gzip) 66.27 KB (+0.81% 🔺)
claudius.iife.js (brotli) 58.23 KB (+0.89% 🔺)
claudius.js (raw) 97.53 KB (+2.05% 🔺)
claudius.js (gzip) 26.81 KB (+2.61% 🔺)
claudius.js (brotli) 22.98 KB (+2.44% 🔺)
claudius.css (raw) 23.53 KB (+1.86% 🔺)
claudius.css (gzip) 4.96 KB (+0.6% 🔺)
claudius.css (brotli) 4.38 KB (+0.7% 🔺)

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Deploying chat-widget with  Cloudflare Pages  Cloudflare Pages

Latest commit: e7c162d
Status: ✅  Deploy successful!
Preview URL: https://d93b5dfd.chat-widget-ejc.pages.dev
Branch Preview URL: https://54-voice-review-fixes.chat-widget-ejc.pages.dev

View logs

@PAMulligan
PAMulligan merged commit 307278e into main Sep 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant