Firefox in CI reports one microphone and no outputs, so the menu has a
single selectable row and hovering the second timed out. The test now
drives the pointer by coordinate over the first row, which holds however
many devices a browser reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu focuses whatever the pointer is over, so `:focus-visible` was
being asked a question it cannot answer. Chromium calls every focus
after any key press keyboard-driven, so one Escape left the border
following the mouse for the rest of the session; Firefox never calls a
programmatic focus keyboard-driven, so the border never appeared there
at all. Neither is what D13 asks for.
The menu now notes which of the two reached the current item, and the
border follows that. Nothing existing covers this: Compound exports no
such helper, the repo has no react-aria, and Radix marks both modalities
alike. `:focus-visible:not(:hover)` would hide the border under the
cursor but still leave Firefox without one.
Two acceptance criteria named checks that could not fail for the reason
they exist. AC24 was manual and now names an e2e test that reads the
painted outline as the pointer and keyboard take turns. AC15 asserted
the attribute its stylesheet keys off rather than the greying, and now
names the meter's own story; deleting the CSS rule fails that story and
not the unit test. Both edits made with the owner's authorisation.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md — AC15, AC24.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Building the analyser can fail after getUserMedia has already resolved,
and the capture then outlived its own failure: the microphone stayed
open, and its in-use light on, behind a meter reporting the microphone
as unavailable, until the menu closed.
Cleanup did two jobs under one name. Releasing what has been acquired is
now its own step, which teardown and the failure path both take.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu built the label for a default output by joining a translated
"Default" to the device name, which leaves a translator no say over word
order, spacing or the brackets.
It now interpolates a string of its own. Not `default_named`, whose
markup styles the parenthetical for the settings dialog: a menu item's
label is plain text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With no microphone attached the menu drew a level indicator resting at
zero, announcing "No sound detected" — which is exactly what a working
but silent microphone draws. The two states people most need to tell
apart looked the same.
The level state now distinguishes a microphone that is absent from one
that is merely quiet, and the microphone group names the absence where
the indicator would be, as it already does for a denied permission. A
microphone that is merely slow to open still draws at rest, so the
indicator is on screen as soon as the menu is.
The product spec listed this as an edge case but no requirement carried
it, so no acceptance criterion covered it either. Added as AC28 with the
owner's authorisation, and the product spec's edge case reworded to say
what the menu does rather than that it shows nothing.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md — AC28.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The meter reported a fresh level on every animation frame, so the whole
menu reconciled around sixty times a second for as long as it was open,
silence included.
The level is now rounded to the number of bars the meter draws, and the
previous value is kept whenever that has not moved, so a frame with
nothing new to draw re-renders nothing and silence costs nothing at all.
The capture hook also moved into a component of its own, so a level that
does change redraws the meter rather than the device rows and the slider
beside it.
The bar count is what sets that resolution, and the hook is told it
rather than naming a number of its own.
Three tests count commits per animation frame, an effect with no
dependencies standing in for a paint: silence commits nothing, a steady
tone commits only until the level settles, and the reported level is
always one the meter can draw.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md — no acceptance criterion
changes; AC8 and AC12 hold as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lobby footer reads the same device behaviours as the in-call one, so
the menu already reaches every platform before joining, and stays gated
to desktop during a call. These are the checks that hold it there: the
view model offers the menu pre-join on desktop, Android and iOS, and the
microphone button stands alone with no chevron once a call starts on
mobile.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 5 — AC18, AC19.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu gains a third group with the sound-effect volume slider, reading and
writing the same stored value as the slider in settings, so the next effect
plays at the new level. The menu is bounded to the height Radix reports for it
and only the device lists scroll; the heading and the slider stay in place.
Every control is now reachable by keyboard alone. Radix swallows Tab inside
its menus so that the arrow keys walk the items; the audio menu keeps Tab
from it, letting the browser move focus from the device rows to the meter and
on to the slider, while the menu's focus trap keeps that order inside the
menu. The slider's own keys stop at the slider so the menu does not treat
them as navigation. The meter keeps its live-region text on blur: removing it
handed the focus trap an empty active element mid-Tab and pulled focus back
into the menu.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 4 — AC4, AC16, AC17,
AC20, AC21 (manual), AC22, AC27.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The meter sits at the foot of the microphone group and follows the selected
microphone. Its capture starts when the menu opens and stops when it closes,
muted or not, so the microphone is only held while the user is looking at the
level. A denied permission shows a hint in place of the meter.
Keyboard navigation marks the current menu item with a focus border; pointer
use gets the hover background only, since the menu moves DOM focus to
whatever the pointer is over.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 3 — AC9, AC10, AC12,
AC14, AC23, AC24 (manual), AC26.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
useMicrophoneLevel opens a capture of the given microphone for as long as it
is enabled, reports a smoothed 0..1 level per animation frame, and tears the
capture down on disable, device change or unmount, including while the
capture is still being acquired. A denied permission and a microphone that
cannot be opened are reported as distinct states.
AudioLevelMeter renders that state as a row of bars, focusable and announcing
its state to a screen reader only while focused, with a hint in place of the
meter when permission is denied and a greyed-out meter when the microphone is
unavailable. Nothing renders it yet; the audio menu picks it up next.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 2 — AC8, AC11, AC13,
AC15.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The chevron beside the microphone button now opens a menu headed "Audio
controls" that lists the microphones and, below a separator, the audio
outputs. Selecting either switches the device and keeps the menu open.
Where the browser offers no choice of output, or only one, the speaker
group still names the output in use as a non-selectable row.
The footer view model derives the outputs from the same MediaDevices
helper as the device lists, so the menu reaches both surfaces at once:
in a call on desktop, and before joining on every platform.
Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 1 — AC1, AC2, AC3,
AC5, AC6, AC7, AC25.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec, its product sidecar and its notes, as of the exploration branch
fkwp/feature/audio_quick_menu_exploration at 39d2ffbf where it reached status
implement, plus the drift log this implementation appended: the drift check
against a fresh main, the re-derived slicing plan, the owner's decision to
land the feature as one PR, and the findings from building it.
The commits that follow are its slices, in the order the plan gives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FEATURES_SPEC/AGENTS.md describes how an exploration is distilled into a
feature spec and how a spec is turned into PRs against a current main; the
product spec template is the sidecar a brainstorming session fills in.
The folder is excluded from oxfmt: a product spec is immutable once a spec
leaves draft, and the process documents are prose in their own style.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codifies what is currently tribal knowledge or only discoverable by reading CI
config: the view model / view contract, the rule that nothing reads the page,
Clean Code ordering, the three test layers, and reuse-before-you-build.
The root AGENTS.md is the always-loaded minimum and routes to docs/agents/, so
an agent loads only the detail its task needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IMO this is where the delegation calls should have lived all along, since the leave event is part of the membership lifecycle, and we otherwise end up with an awkward hack to ignore transport updates.
Doing this now ensures that the client won't send any delegation requests if delegation is unsupported, and prepares the code for a future change in which we use the dedicated delegation endpoint from the CS API.
Since we removed the extra 'legacy' mode, meaning there's nothing simplified anymore by translating things over to a JWT service version.
(Also, simplify some things by making the mode static for the duration of a call.)
`downloadMedia` exists because a widget's client cannot fetch media: it
has no access token, and its only route to a file is MSC4039 over the
widget API. A component host hands over a client that does hold
credentials, so Element Call fetches media with it and the callback was
never needed there. It comes off `ElementCallHostBridge`, leaving a host
with only what it must understand, and stays on the internal HostBridge
for the widget.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The theme is state — what Element Call should look like right now — and
so belongs beside `language` as a prop, not on the imperative handle
(where it was a request, `setTheme`, because the internal host bridge
speaks the widget API and a widget's host sends theme changes as
requests) and not in the configuration (where `config.theme` only ever
set the starting theme).
The `theme` prop feeds the same channel the rest of Element Call listens
to for a host's theme, replayed so that whatever subscribes after the
host has set it still hears the current one. Changing it re-themes the
container and nothing else; unlike the language, it is per component.
`setTheme` and `config.theme` are gone, and the harness gets a theme
picker in place of its per-pane buttons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The component bundled English alone: the standalone app fetches its
locale files at runtime from URLs its own build emits, which a host
serving the library from elsewhere could not resolve, so bundling one
language was the self-contained option. Now every locale is a chunk of
its own that the host's bundler loads the first time it is needed, with
English still bundled in so that the fallback never waits.
Element Call starts in the browser's language and follows the host's own
setting through a `language` prop; `supportedLanguages` says what it
accepts. Translations are shared by every Element Call on the page, so
the most recently set language wins for all of them. The harness gets a
language picker, and the app and the component share the parsing of
locale paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The call reached for react-router in five places to send the user
"home": on leaving without a lobby, from the lobby's recents link, from
the post-call screen, from the error page's return button and from the
header logo. Home is the standalone app's home page; the call has no
idea where that is, and a component has no such place at all — its host
decides what follows a call. Yet the component had to mount a
MemoryRouter just so those hooks would not throw.
`useLeaveToHome` is the way home as the shell supplies it: the app
provides `navigate("/")` from inside its router, the component provides
nothing, and everything that used to link to "/" now either calls it or,
when there is none, offers no way out. The logo becomes a plain logo,
the recents and "not now" links disappear, the error page's button does
too. `ClientProvider`'s logout goes the same way. The component no
longer renders a router.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ElementCallConfiguration` was every URL parameter, which put the widget
plumbing (widgetId, parentUrl, isWidget), the user's identity, the
homeserver, the analytics and Sentry settings, and the standalone app's
shared room secret in front of a host that has no business setting any
of them — the component reads none of those. It is now the behavioural
configuration a widget's URL can carry plus the two properties a host
has a say in: the theme and the background. The background is now
actually applied to the component's root, which it was not before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Embedded" already means something here: the embedded package is the
widget build. Where a comment meant Element Call running as a React
component inside a host application, it now says so, and the params it
starts from are `componentProperties`. "Host" stays the word for the
application on the other side, whether that is a widget container or an
application rendering the component.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three layers stood between a shell and the call: `ElementCall` (the
component), `ElementCallView` and `GroupCallView`. The middle one only
held the `joined` flag and the mute state, both of which nothing outside
the call reads any more, so it is folded into the view it wrapped. That
view is now `CallView`, since "group call" is a name from before
Element Call handled anything else.
Its docstring says what it is: the whole lifecycle of a call — lobby,
the call itself, and what comes after — with the lobby and the post-call
screen each present or skipped depending on the parameters and the host.
Both the standalone RoomPage and the component render it directly.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The component exposed the internal HostBridge to hosts as-is, which
carried the host's requests as rxjs observables. That made rxjs part of
the public API of a package that bundles its own copy of it, so a host
would build bridges with a different rxjs than the one Element Call
consumed them with — and asked every host to learn rxjs to change the
theme.
A component host now implements plain async callbacks for what Element
Call tells it (`ElementCallHostBridge`, all optional), and makes its own
requests through an imperative handle on the component's `ref`
(`ElementCallHandle`: setTheme, join, hangUp, setDeviceMute), each
resolving once Element Call has acted and rejecting when nothing in
Element Call can. `component/host.ts` adapts that to the HostBridge the
rest of Element Call still speaks, with a bridge whose identity never
changes, so a host re-creating its callbacks on render restarts nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only the call body sat inside an error boundary; anything thrown in the
lobby, the mute state or the providers propagated into the host's React
tree, where React unmounts everything up to the nearest boundary — the
host's, or the host's whole application. The standalone app has a
boundary at its top; the component now has the same, showing the error
page inside its container and asking the host to stop keeping it on
screen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The move from media queries to `@container element-call` queries left a
few places still asking the viewport: the room header's compact size
and the lobby's footer placement went through `useMediaQuery`, the
lobby's video preview was `50vh` tall, the reaction picker was capped at
`100vw`, and the content insets and the gradient background were sized
from `100vw`/`100vh`. Embedded in a corner of a host's page, each of
those answered for the page rather than the corner.
`useRootSizeMatches` is `useMediaQuery` for the root element, built on
the same `observeElementSize$` the layout uses; the lengths become
container units. Container units resolve against the nearest query
container, and there are others in the tree (the spotlight layouts, the
media tiles), so base.css says when they may be used.
jsdom gives the body no size at all, which would have every such query
read as a tiny window; the test setup now gives it a desktop's, matching
what the media query mock already answered.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Whether to offer the profile settings was inferred from whether the host
could close Element Call. For a component with no host bridge — the
default — nothing could, so an embedded Element Call let the user edit
the profile of an account that belongs to the host application.
`HostBridge.supportsProfileChanges` states it directly: true standalone,
where Element Call signed the user in itself; false for a widget's host
and for anything embedding the component (which sets it itself, since
the client it hands over is its own). The profile tab and the profile
shortcut follow that. What a host's ability to close us still decides —
what to show after the call ends — is a question about who owns our
lifetime, and stays keyed on `close`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The formatter wants the layered import of the Compound design tokens on
one line; "de-globalise styles" left it wrapped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"de-globalise styles" gave the tab's <pre> elements a class instead of
styling the bare element, but did not update the snapshot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The call and the lobby each set `document.title`, so a component
embedded in a host renamed the host's tab to "Element Call | <room>".
The title belongs to whoever owns the page: the standalone app's
RoomPage now sets it, for whichever room it has got as far as knowing
about, and the call itself no longer touches it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The call's shortcuts were listened for on the window and allowed
whenever focus was inside the standalone app's `#root` — which, for a
component embedded in a host, is the host's own root, or nothing. So m,
v and space fired while the user typed in the host's composer, and two
Element Calls on one page both answered every key.
Listen on the element Element Call treats as its root instead (the body
standalone, so nothing changes there), and judge whether a key press is
spoken for by what has focus — a dialog or a text field — rather than by
where it sits in the DOM, since the modals are now portalled to that
same root.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The component built its `MediaDevices` once, in a scope nothing ever
ended, so every mount left device observers running for the rest of the
page's life. Building it in an effect ties the scope to the component's
lifetime — and to the options it was built with, which were previously
frozen at first render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Everything downstream of the component's params — the mute state, the
call view model and with it the media connection — is keyed on the
identity of the params object, which was memoised on the identity of
the `config` prop. A host writing `config={{ ... }}` inline, which is
the natural way to write it, therefore tore the whole call down on
every render. The harness happened to pass a constant, so nothing
noticed.
`useStableValue` hands out the same object for as long as a deep
comparison says nothing changed, so an inline config costs nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>