- main stopped labelling the virtual default output with the first device's
name (426acd22): a pinned sink is not re-routed, and which device the browser
default resolves to is not knowable from here.
- The exploration still carried the older file, so this check passed locally
and failed in CI, which tests the branch merged with main.
- AC8 is unchanged — Default is still a distinct entry beside the device it
resolves to. Only the label it carries has moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Each section is headed by its own rule, edge to edge, instead of one inset
divider between the sections. Compound draws that rule under a menu heading
already, so this removes our Separator, its spacing rule and its import.
- Our heading padding override goes too: Compound's menu heading is the design
already — 13px/600 on a 19.5px line, #656D77, 16px either side, 7px from the
text to the rule. Ours had been moving that 7px above the text.
- Two spacings do differ from Compound, both measured off the mock rather than
guessed: 20px below the rule before a section's first device (27px rule to
control, against Compound's 16px), and 28px above a heading that follows
another section (41px from the last control to the next heading's text,
against 22px). The first heading keeps Compound's spacing — the menu's own
padding sits above it.
- Guarded by relationships, not numbers: the first control sits further below
the rule than from the menu's edge, and a section stands further from the one
above it than a heading does from its own first device. Both fail if either
spacing goes back to Compound's 8px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Covers AC28, which `ux_design_shevron.png` settles.
- Not written in this session: it was uncommitted in the tree beforehand, and
is committed here so it is not lost.
- The stories supply what the app supplies rather than what a harness has
lying about: a microphone, and a call-sized root element.
- Invented device ids meant the menu asked for hardware that does not exist,
so the meter reported no microphone. A tone played into a real MediaStream
fixes it, and the meter runs its own analyser over it.
- Without a root element the list was bounded by Storybook's whole frame and
the menu ran off the canvas; without a call-sized one, four devices scrolled.
- Standalone specs: the switch that does not drop the call, the meter alive
while muted, the pinned meter, the focus ring.
- Component specs: the list sized to the call and not the window, resized
while open, every device reachable, and the modality belonging to one call.
- A headless browser has one microphone and one speaker, so fake-devices.ts
adds synthetic ones. It is explicit that it does not route audio.
- Both arms of the switch were the same eleven lines of click, disabled, busy
and enabled wiring; only the component and the test id differed.
- Two copies that had to be kept in sync by hand, for nothing.
- `toggles` becomes an expression rather than a `let` mutated in a switch,
which makes the optional chaining downstream dead.
- The labels switch stays: i18n extraction needs literal `t("…")` keys, and it
gives exhaustiveness checking on the union.
- Was watched at the document, so a key pressed in another Element Call on the
same page — or in the host's own page — lit this menu's ring. Element Call
can be mounted more than once, and the menu is portalled out of the root.
- Watched on the menu instead, which is the element that holds focus.
- A callback ref, not an effect on `menuOpen`: that state is ours and the open
menu is Radix's, and they do not commit together, so the effect could run
before there was anything to attach to.
- Recorded on the menu rather than in state, so every item it can focus answers
to it — the camera menu's blur toggle kept the browser's own ring otherwise,
which follows the pointer.
- Nothing re-renders when the modality changes now, so the reset folds in with
the listeners and the second effect on that state goes.
- Comments moved to the code they describe; three unrelated ones had stacked up
on one declaration.
- The heading of the section you are reading stays at the top of the list and
leaves with its own section, so a long list never leaves you guessing which
kind of device a row is.
- Opaque and held a border width clear of the sides, like the meter: a
positioned element paints over the outline the menu draws its frame with.
- Sticky alone hides rows: scrolling a row flush to either edge puts it under
the heading or under the meter, which is how a row reached by keyboard ends
up half-readable. scroll-padding-block keeps both heights clear.
- Measured from the real elements, not from tokens — the meter's failure states
are two lines where a level is one.
- The bound was taken once when the menu opened, so a host resizing the space
Element Call is drawn in while the menu is open left it describing a call
area that no longer exists.
- Follow it with observeElementSize$, which was already here and already used
by useRootSizeMatches, and only while the menu is open.
- Quantised before it reaches React, so a drag-resize re-renders only when the
bound itself moves.
- 160 and 0.6 are named now, with the reason each exists.
- Safari enumerates no output devices at all, so the list arrived empty — and
an empty array is truthy, so the Speaker heading rendered with nothing under
it, which reads as a broken feature.
- Show one entry instead, named as the default, disabled, and marked as the
selection: audio is going somewhere, and an unchecked lone row reads as
nothing being chosen.
- D2 already says a row that cannot be chosen is shown disabled, never hidden.
- Covered by its condition rather than by the browser, so Linux CI checks it:
one test that the view model hands over an empty list rather than none, one
that the menu then draws the default.
- Measured: the icon's centre sat 3px right of the radio controls above it.
- Two causes, both off the padding: the row is inset a border width to keep
the menu's frame visible, and the icon is wider than a radio control.
- Vertically the icon was right and the text was not — a paragraph's bottom
margin means the margin box gets centred, lifting the words.
- Guarded in a story, not a unit test: jsdom lays nothing out.
- A bar and the gap beside it are fixed; how many bars there are follows the
space. Spread gaps made the meter a different shape everywhere, and closed
the bars into one block when space ran short.
- Bars that touch cannot be counted, and the count is what carries the level
without colour.
- `contain: inline-size` and `min-inline-size: 0` so the width decides the
count and not the reverse — without them the bars set the menu's width.
- Gap is half again the bar width, measured off the design mock: 10px bars
14px apart, a ratio of 1.4, nearest token pair 4px and 6px.
- Sizes stay in the stylesheet; the component reads them back off a rendered
bar rather than repeating them.
- The capture, the analyser loop and the loudness maths were in a component
hook. Call logic belongs in the state layer; components render.
- observeMicrophoneState$ opens the device when something subscribes and
releases it when nothing does. The hook is left as a bridge, no domain logic.
- Fixes a leak. A permission prompt outlives the menu that opened it, so the
browser could hand over a microphone after cleanup had already run — with the
variable that cleanup would have released still unset. The device stayed
held, indicator lit, no meter on screen.
- Expressed now as the subscriber being closed, which the observable can always
answer.
- The hook had no tests at all, which is why this survived. Four now, and the
capture stub they need is in the shared factories rather than hand-rolled.
- One counts emissions across twenty animation frames and expects one: the
redraw cost AGENTS.md asks continuous drawing to account for.
- Show the focus ring only when the keyboard moved the focus. Radix focuses
whatever the pointer is over, so the browser cannot answer that question
here: Chromium treats every focus after a key press as keyboard-driven,
Firefox treats no programmatic focus as keyboard-driven. The menu records
which modality arrived, watched at the document while it is open, since the
first arrow key lands on the menu itself rather than on anything we render.
- Suppress the browser's own ring on menu items, so there is one answer to
that question rather than two.
- Drop readOnly from the device radios. Compound paints a read-only control
muted, and that rule comes after the checked rule, so it overrode the accent
fill marking the selection and the menu stopped matching settings.
- Make the decorative radio inert rather than aria-hidden. A negative tabindex
inside an interactive control stays reachable to assistive technology even
when hidden, which axe rejects.
- Give each section a labelled group and mark its heading decorative. A menu
may contain only items, separators and groups, and the headings were direct
children of it. The list wrappers are role="none" for the same reason.
- Run axe over the open menu, which is what found both of the above.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC23, AC24, AC25
- Bound the scrolling device list by the measured height of the call area.
It was capped with a viewport unit and a Radix variable, both of which
measure the window: as a component in a corner of a host's page, the menu
would have been sized against the whole page. The menu is portalled outside
the root, so no container query reaches it and it has to be measured.
- Leave a single divider between the two lists, as the design has. Compound
underlines every menu heading, so the headings drop theirs, and the line
closing the microphone section goes: the section wrapper already bounds the
meter's stickiness without one.
- Cover device persistence, hot-plug, fallback when the device in use is
removed, fallback to a default when a remembered device is gone, numbered
labels before permission, "Default" listed as its own entry, one device kind
not disturbing another, and camera parity. Their acceptance criteria named
check commands for tests that did not exist, which makes the criteria
unenforceable and blocks the next drift check.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC3, AC4, AC5, AC8, AC9,
AC10, AC11, AC19, AC20
- Draw the level beneath the microphone list as 24 segments, announced
through role="meter", so it reads by count and not by hue alone.
- Give denied permission and no input device their own message and next
action, rather than a flat meter that reads as silence.
- Read the selected device, and only while the menu is open, so nothing holds
a second capture for the length of a call.
- Follow a rise quickly and a fall slowly, in elapsed time rather than frames,
so the meter does not chase the gaps between syllables.
- Ignore a noise floor, below which a quiet room's hiss would light the first
segments permanently. Smoothing changes how the meter moves, not where it
settles, so it does not replace this.
- Scroll only the device lists, and keep the meter pinned to the foot of the
microphone section while its rows are in view.
- Withhold the output select callback where the platform cannot route audio
to a chosen device, which is what renders the speaker section disabled.
- Track the in-flight device by kind as well as id: Chrome names both an
input and an output "default", so the spinner lit the wrong row.
- Disable every device while a selection is settling, so a second request
cannot overtake the first.
Two CSS choices are load-bearing and look arbitrary: the meter's wrapper is
unpositioned, because a positioned one paints above the menu's outline and
swallows the frame along that section, and the meter is held clear of that
outline, because it is the only opaque thing in the menu.
The meter opens its own short-lived capture rather than tapping the call's
audio track. That lets it follow the picker instantly and avoids the pre-join
track, which is frozen to the device selected when the screen mounted. The
cost is a second capture while the menu is open. See the notes sidecar.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC11, AC13,
AC14, AC15, AC17, AC18, AC21, AC22
- Draw the input level beneath the microphone list as 32 discrete segments,
so the level reads by count and not by hue alone.
- Expose it through role="meter" with aria-valuenow and aria-valuetext, so a
screen reader announces it.
- Give denied permission and no input device their own message and next
action, rather than a flat meter that reads as silence.
- Read the level only while the menu is open, so nothing holds a second
capture of the microphone for the length of a call.
- Read the device the picker has selected, so the meter re-points as soon as
the selection changes.
- Ignore anything below a noise floor: a quiet room is never digitally
silent, and its hiss otherwise lights the first segments permanently, which
reads as "it can hear me" when nobody is speaking.
- Quantise loudness with a square root above that floor: amplitude puts
ordinary speech near the bottom of the range, where a linear meter barely
moves.
- Withhold the output select callback where the platform cannot route audio
to a chosen device, which is what renders the speaker section disabled.
Safari and most Firefox builds land here.
- Track the in-flight device by kind as well as id: Chrome names both an
input and an output "default", so the spinner appeared on the wrong row.
- Disable every device in the menu while a selection is settling, so a second
request cannot overtake the first.
The meter opens its own short-lived capture rather than tapping the call's
audio track. That is what lets it follow the picker instantly, and it avoids
the pre-join track, which is deliberately frozen to the device selected when
the screen mounted. The cost is a second capture of the same device while the
menu is open. See the spec's notes sidecar.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC13, AC14,
AC15, AC17, AC18, AC21, AC22
- Mark the selection with a radio control instead of a trailing check icon
beside a device glyph, matching the design and the settings modal, so both
device pickers read the same way.
- Take the visual from Compound's RadioInput rather than restyling a span of
our own. RadioControl is wrapped in a Radix form control and needs a Form
ancestor, which a dropdown menu has no business providing; RadioInput does
not.
- Keep rows as menuitemradio with aria-checked, rendered through
MenuItem as="div" so an input is never nested inside a button.
- Render the radio aria-hidden and not focusable: Radix owns focus inside the
menu, and aria-checked carries the state.
- Leave the activating spinner unchanged.
- Keep every scope.behavior call inside the function that owns the scope,
which no-observablescope-leak requires.
- Cover the speaker section, an output that cannot be chosen, and a lone
device shown disabled, with unit tests and stories for each.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC7
The microphone chevron listed input devices only, so changing the output
device meant leaving the call controls for the settings modal.
Render the audio menu as two sections, Speaker above Microphone, and wire
the output device list, selection and select callback through the footer
view model. Entries are shown but disabled where the platform cannot switch
that kind of device, or where only one exists, so the menu keeps the same
shape everywhere.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC8
LiveKit's livekit-ice logger records each local ICE candidate, ICE
candidate errors and ICE connection state changes, but only at debug,
which we cap to info unless extended LiveKit logs are switched on. That
means a 'could not establish pc connection' rageshake carries no way to
tell 'no relay candidates at all' from 'relay candidates that never
paired'. Keep that one logger at debug regardless of the toggle.
* Add AGENTS.md documenting the repo's conventions
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>
* Allow either hand at the commit, and measure continuous redraws
Committing from the agent rules out running it where there is no git
identity, a sandbox included. Either is now fine: commit on the user's
word where the setup allows it, or write the message and hand it over.
Redrawing continuously is the other addition. A level meter reported a
fresh value every animation frame, so its whole menu reconciled sixty
times a second, silence included, and nobody noticed until review. A PR
that adds anything of the kind now says what it costs, or better, counts
the redraws — commits rather than render calls, since React runs a
component it then discards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "has no matching matrix call member" warning fired on every re-render
(i.e. every active speaker update) and also for the local participant's own
track, which is never rendered here anyway. Skip local tracks and warn once
per unexpected identity.