The audio-locked attribute was correctly setting `muted = true` and posting
`set-muted` to the iframe runtime, but on warm-cache reloads of claude.ai
and inside the Claude desktop Electron client, the iframe finishes loading
*after* the parent has already sent control messages — the iframe runtime's
postMessage listener isn't installed yet, so the messages are silently
dropped. Audio plays unmuted with no UI to recover.
Confirmed via:
- "First open" on claude.ai: cold cache, iframe slow → listener up before
`set-muted` lands → audio muted ✅
- "Hard refresh" on claude.ai: warm cache, iframe fast → listener up after
message arrives → message lost → audio plays ❌
- Claude desktop: Electron renderer consistently fast → race always loses
→ audio plays ❌
Fix: add a `{source: "hf-preview", type: "ready"}` event the runtime emits
once `installRuntimeControlBridge` has registered the listener. The player
listens for it and replays current bridge state (`set-muted`, `set-volume`,
`set-playback-rate`). Pre-ready messages are now safe to send — they'll be
replayed once the runtime can receive them.
The replay is idempotent — re-asserting defaults is a no-op — so it's also
safe across iframe reloads (new runtime instance emits ready again).
Tests: 6 new (1 bridge: ready posted on install; 5 player: replays muted /
volume / playback-rate / audio-locked-forced-mute / handles second ready /
ignores ready from wrong source). Suites green: core 1387, player 137.
Refs:
- Investigation: heygen-com/hyperframes#1300 (UA-fallback attempt — unrelated
to actual root cause)
- claude.ai-web.log analysis revealed cross-origin iframe + race condition,
not attribute stripping as originally hypothesized
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Claude desktop Electron client appears to strip the `audio-locked`
custom-element attribute before it reaches the DOM, so chat-host audio
still plays even though Claude web (which preserves the attribute)
correctly mutes. Verified via DevTools: web renders `<hyperframes-player
audio-locked>` and is silent; desktop omits the attribute and plays sound.
Self-impose the same restriction when `navigator.userAgent` matches the
Claude desktop UA (Claude/<ver> + Electron). Internally route everything
through a new `_isAudioLocked()` helper — attribute OR host fallback —
and apply the lock from `connectedCallback` since `attributeChangedCallback`
never fires when the attribute is missing.
The public `audioLocked` property still reflects only the attribute, so
external consumers (e.g. pacific widget mirroring state) are unaffected
by the safety net.
Tests: 6 new (forces mute on Claude desktop UA, re-asserts on unmute,
hides controls, no-op for regular browsers, no-op for non-Claude Electron
apps, public property remains attribute-only). Player suite green:
132 tests.
Refs: pacific #28773, experiment-framework #38809.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>