Skip to content

refactor(voices): row taxonomy + model-driven idempotent render for the voice menus#492

Merged
rosscado merged 6 commits into
mainfrom
refactor/voice-menu-rows
Jul 4, 2026
Merged

refactor(voices): row taxonomy + model-driven idempotent render for the voice menus#492
rosscado merged 6 commits into
mainfrom
refactor/voice-menu-rows

Conversation

@rosscado

@rosscado rosscado commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Why

The same failure kept recurring in src/tts/VoiceMenu.ts: #485 (bulk querySelectorAll("button") teardown crashed on #482's nested ▶), the Patch A pin-wipe / Pi7-8 deletion (imperative re-render on shared state), #483 stuck (a row that IS a <button> can't grow a ▶ sibling), and the classify-by-absence trap (any unmarked element read as a host voice — clicking it would unset the user's voice). Each was the code's shape biting, not a one-off. The caution map lists this refactor as a wanted candidate (§6: "the VoiceMenu populate paths → one button factory + SelectionState").

What changed

src/tts/VoiceMenuControls.ts (new) — positive, exhaustive taxonomy. Every interactive element declares what it IS (custom-voice / restored-voice / door / preview / host-voice); anything unmarked is inert. Host buttons are adopted (stamped + wired) at exactly one site; the stamp doubles as the wired-once guard, fixing the duplicate-listener-per-expand in the old registerVoiceChangeHandler.

src/tts/VoiceMenu.ts — slim generic base. Keeps cross-surface concerns only (auth/pref wiring, introduceVoice, ttsRequiresSignIn) plus the new contract: refreshMenu() gathers the catalog AND the stored voice together, then calls the single abstract renderMenu(voices, storedVoice). Selection/pin state is never mirrored in fields or recovered from the DOM — the stored preference is the model; the DOM is a render of it.

src/tts/GridVoiceSelector.ts (new) — reconciling render for Pi's button grids. Row factory + id-scoped reconciliation (insert/move/remove by data-voice-id; unchanged rows keep DOM identity and listeners). Nothing bulk-removes buttons, so the #485 crash class is structurally impossible, not guarded against. The pin field and its render→detect→re-render recursion die: the stored id goes straight into curateShortlist, whose current-first rule pins synchronously. The row root is still the <button> today — selectTargetOf is the one seam a wrapper-row future (#483's ▶) changes.

Hosts. PiVoiceMenu/PiVoiceSettings move onto the grid (expand listener collapses to one gather-then-render; handleExistingVoiceButtons and the 3-call expand sequence die). ClaudeVoiceMenu migrates to the base contract but its rendering is deliberately NOT redesigned — populateVoices becomes renderMenu(voices, stored), deleting the read-selection-from-button-label block, the async getVoice fallback + pin writeback, and pinnedCustomVoiceId. VoiceCuration.ts untouched. No Preact (per doc/preact-component-conventions.md).

Deliberate behavior changes (small, both improvements)

  • No stale mark: when the stored voice isn't visible in the menu, NO row shows selected (previously the old row could keep its highlight — a lie about what would speak). Next full render surfaces the voice via curation.
  • Claude trigger label is right on first paint (it received the stored voice with the catalog; no more voice-off-then-update flash).

Tests

  • New: VoiceMenuControls.spec (taxonomy), GridVoiceSelector.spec (17 cases: idempotence, DOM-identity, cap/door, extras survival, adoption, ▶-inertness), VoiceMenuInvariants.spec (grandfathering, current-voice-never-vanishes, no-silent-swap, door singularity, adoption idempotence, taxonomy completeness).
  • Existing voice specs transformed to drive renderMenu directly; every product invariant assertion preserved (and the flushAsync pin choreography deleted).
  • Gates: npm test (tsc + Jest + Vitest, 198 files / 1755 tests) ✅ · Layer 3 E2E (13/13) ✅

Layer-4 real-host evidence

Unblocks #483; refs #485, #470/#472, #491.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y1oVsdBKdATjojxQprFhR4

rosscado and others added 6 commits July 4, 2026 18:27
Every interactive element in a voice menu now declares what it IS
(custom-voice / restored-voice / door / preview / host-voice); anything
unmarked is inert. Replaces the classify-by-absence isBuiltInVoiceButton
model whose open-world negative match made every new element a host voice
until proven otherwise (the ▶-deselects trap, the #485 crash class).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y1oVsdBKdATjojxQprFhR4
…ctor

The base keeps only cross-surface concerns (auth/pref wiring, a
gather-then-render refreshMenu, introduceVoice); renderMenu(voices, stored)
is the single idempotent render path. GridVoiceSelector owns the button-grid
surfaces: row factory, id-scoped reconciliation (no bulk button teardown —
the #485 crash class is structurally gone), one-site host-row adoption via
the positive taxonomy, and selection as a pure render of the stored
preference. The pin field and its render-then-detect-then-re-render
recursion die: the stored voice is fetched WITH the catalog and handed to
curateShortlist, whose current-first rule pins synchronously.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y1oVsdBKdATjojxQprFhR4
The expand listener collapses to one gather-then-render (TTS-off still
adopts host rows, tops up Pi extras, and keeps the door); PiVoiceSettings'
four-call constructor sequence becomes refreshMenu(). Existing specs are
transformed to drive renderMenu(voices, stored) directly — every product
invariant assertion (cap, door, uncapped grid, tier badge, hidden-stored-
voice visibility, Pi7/8 survival, stale-pin clearance) is preserved, now
without flushAsync pin-recursion choreography.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y1oVsdBKdATjojxQprFhR4
populateVoices(voices, el) becomes renderMenu(voices, storedVoice): the
stored voice arrives with the catalog (base gather-then-render), so the
trigger label is right on first paint, curateShortlist pins the current
voice synchronously, and three mechanisms die — the read-selection-from-
button-label block, the async getVoice fallback with its pin writeback and
conditional re-populate, and the pinnedCustomVoiceId field. applySelectedVoice
shrinks to a pure re-mark (never tears down an open menu). Claude specs
updated to drive renderMenu directly; every invariant assertion preserved.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y1oVsdBKdATjojxQprFhR4
Grandfathering (deprecated stored voice renders + selectable), current-
voice-never-vanishes under shuffled re-renders, no-silent-swap (incl. the
deliberate no-mark-when-hidden improvement), door singularity + placement,
adoption idempotence across render + observer paths, and taxonomy
completeness (every grid-created control classifies non-unknown).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y1oVsdBKdATjojxQprFhR4
@rosscado rosscado merged commit 02b0ba1 into main Jul 4, 2026
2 checks passed
@rosscado rosscado deleted the refactor/voice-menu-rows branch July 4, 2026 17:55
rosscado added a commit that referenced this pull request Jul 4, 2026
…495)

Every open runs renderMenu via refreshMenu, whose fetches resolve from
warm in-memory caches in microtasks — which trusted clicks let run
BETWEEN the trigger's listener and the document outside-click listener.
The wipe-and-rebuild render detached the click's event.target mid-
bubble, so contains(event.target) read the opening click as "outside"
and closed the menu in the tick it opened. (Masked pre-#492 by the old
base refreshMenu's 100ms sleep; invisible to synthetic el.click(),
which dispatches without microtask checkpoints between listeners.)

The trigger and menu container are now stable nodes: created once,
updated/refilled in place (replaceChildren), duplicate defense stays in
the constructor. Verified on live claude.ai with a trusted-click CDP
probe: repro'd {display:none, aria-expanded:false} on the old build,
double-open green on this one.


Claude-Session: https://claude.ai/code/session_019NoPTGPfcHjnjkcBWZqR6C

Co-authored-by: Claude Fable 5 <[email protected]>
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