Commit Graph
7590 Commits
Author SHA1 Message Date
fkwpandClaude Opus 5 8ffc8e2720 Sit the notice as far from the menu's foot as from its sides
- The menu carries bottom padding of its own, so setting one on the notice
  as well made the gap under it twice the gap beside it.
- Guarded as the relationship rather than the number, which is what anyone
  looking at it would notice. Fails at 32 against 16 without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:04:40 +02:00
fkwpandClaude Opus 5 508fde1029 Say what the browser costs without raising its voice
- The design's second pass: the subtle ground, an outline mark and text at
  its ordinary weight, sixteen pixels in from the menu's sides like the
  grid above it. Measured against the drawing, the ground and the inset
  land on it exactly, and the padding gives the same box for the same
  number of lines.
- No longer an alert. An alert is for something that just happened; this
  was true before the menu opened and will be true after it closes, which
  is also why it cannot be dismissed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:59:25 +02:00
fkwpandClaude Opus 5 a2fc94a882 Guard that a shipped background can cover something
- Reads each shipped file and requires a format with no alpha channel in it.
  Fails on the overlay scrims that started this, which is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:10:26 +02:00
fkwpandClaude Opus 5 dfd42e2edd Give a background something to cover with
- The stand-in backgrounds were the app's own gradients, which are overlay
  scrims: measured, not one pixel in either is opaque, and where they look
  dark they are transparent. As a background their dark half was simply
  absent, which is what showed on the slow path.
- They are now laid on the canvas colour they are painted over and kept as
  JPEG, which cannot carry transparency at all.
- The same hole was waiting for anyone's own picture: a canvas starts
  transparent, WebP keeps an alpha channel, and a file small enough to keep
  as it was never passed through either. Every picture now goes through an
  opaque ground, whatever its size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:59:07 +02:00
fkwpandClaude Opus 5 bfc42d8d7f Lay the background effect states out for review
- A docs page putting the four states side by side: offered, offered with
  what it costs, being set up, and not possible at all.
- The settling story plays the sequence rather than posing it, shortened so
  it is watchable.
- The notices carry the real strings, so a reviewer reads the words a user
  would rather than a paraphrase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:49:40 +02:00
fkwpandClaude Opus 5 5b8e7d8cfd Offer effects wherever they run, and say what they cost
- Drops the desktop test. Measured, it sorted devices by the wrong thing:
  a phone on the fast path held 99% of its frame rate and was refused, a
  desktop on the slow one held 73% and was allowed.
- What the cost tracks is the processing path, and Safari and Firefox take
  the slow one on every platform. That is told, not enforced: the cost is
  smoothness, the reason to pay it is privacy, and only the user knows
  whether they would rather show the room they are sitting in.
- The first effect of a session takes a while to prepare, so its own tile
  spins where its tick will go, until a frame has actually come out of the
  pipeline. The promises say nothing useful about when that is: attaching
  resolves in about three seconds and switching in none at all, and the
  slow path then holds the page still for twelve to fifteen more. The
  spinner turns by rotating, which a browser composites, so it keeps moving
  through a pause that has stopped everything else.
- Copy is the exploration's and needs an owner (S3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:49:40 +02:00
fkwpandClaude Opus 5 2f214c8ca2 Ask the bench where the startup went
- Splits attaching from switching: building the segmenter fetches, compiles
  and sets up the GPU, while switching only tells a built one what to draw.
  One of those can be paid ahead of time.
- Reports the segmenter's assets as the browser saw them. A transfer size
  of zero means cache, which is what separates the first run on a device
  from every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:52:31 +02:00
fkwpandClaude Opus 5 6a4bfa1b9a Put the bench's buckets on the clock
- They closed only when a frame arrived, so a freeze could not be reported
  as one: the iPhone's fourteen frozen seconds came back as a single
  bucket at a seventh of a frame per second.
- Adds the two numbers that say it plainly: time to the first frame, and
  the longest the picture simply stopped.
- A run now ends even if no further frame ever arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:49:07 +02:00
fkwpandClaude Opus 5 1e445a4d14 Measure whether a device can run background effects
- The camera and the pipeline, no call in the way: if a device cannot hold
  a frame rate here it cannot hold one in a call either.
- Reports mean and worst five-second frame rate, startup, and the worst
  stall on the main thread. Run no effect first; every number is only
  meaningful beside that device's own baseline.
- Ignores the desktop gate on purpose. Measuring what it forbids is the
  point, and the gate was written from reports rather than numbers.
- Exploration only, on an unlisted route, and one path segment deep: the
  config is fetched relative to the page, so a nested path never starts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:35:42 +02:00
fkwpandClaude Opus 5 aabd9a7022 Ask one question about whether effects can run
- The menu asked the browser for the APIs; the pipeline asked for those and
  a desktop. A phone browser sat between the two: every effect offered,
  none applied, nothing said.
- Both now ask the same function, so what is offered and what can be
  delivered cannot drift apart again.
- Tested against the lobby, where the switcher stays enabled on a phone —
  in a call it is already withheld there, so a check would pass whatever
  the verdict said. The test fails on the old wiring.
- This also fixes the blur toggle, which had the same split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:14:44 +02:00
fkwpandClaude Opus 5 1299dad4b1 Draw the remove button as design drew it
- Critical from the moment it appears, not only under the pointer: the
  ground carries the hover instead, and gains a ring where the shadow was.
- 28px on the tile's corner, overhanging it by a fifth of itself, with the
  glyph a third of the button as the mock draws it.
- It names itself: a Remove tooltip, so the cross has words.
- Every colour is a Compound token and lands on the mock's own value in
  both themes, bar the dark hover ground, which is one step up the red
  ramp from what the mock measures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:58:13 +02:00
fkwpandClaude Opus 5 4cea64bd07 Format the branch
- oxfmt over the six files the exploration added or changed.
- Wrapping only, no behaviour: the formatter is a separate CI step from
  pnpm lint, which is why it went unnoticed on the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:24:12 +02:00
fkwpandClaude Opus 5 61cc12d937 Take the section rules from the device menu's new design
- The camera menu drew its own rule under each heading, because the menu
  suppressed Compound's. It no longer does, so ours drew a second one:
  the separator, its class and the heading override all go.
- The grid is its section's first control, so it starts under its rule
  where a device row's control starts under that one.
- Guarded in the story: two headings, each ruled edge to edge, nothing
  else dividing them, and both sections' first controls level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:12:29 +02:00
fkwpandClaude Opus 5 c793cd5fc1 Open up the space between the effect tiles
- Measured off the design file: its gap is about a sixth of a tile's
  width, ours was under a tenth
- A step wider brings it to 14.3% against the design's 15.6%, the closest
  a spacing token reaches
- The tiles keep their width and the menu grows instead, which is the
  column floor doing its job; the grid still sits 16px from the frame
- Gives the remove cross on the corner room of its own, which is what
  prompted it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 8a2ad2226d Move the remove cross to the tile's corner
- On the top-right corner, where Zoom and Teams both put it
- Centred, it sat inside the target for choosing the tile: the one
  destructive action lived exactly where the ordinary one does, which is
  what was confusing people
- Red under the pointer. That alone would not have fixed it: colour says
  what a control does, it does not stop the press landing on the wrong one
- The one in force still offers no removal (FR-025). The corner would
  allow it, since cross and tick no longer compete for the middle, but
  that stays product's call

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 c7c44eea4e Put an added background on before joining, not during a call
- Before joining nobody sees the change, so the picture goes on at once
- In a call it waits to be chosen: otherwise choosing a file would change
  what everyone sees with no further word from the user
- Teams draws the line in the same place, which is what settled it: a
  background added before joining applies by itself there, one added in a
  meeting has to be applied
- The footer learns which side of joining it is on from its view model,
  since the lobby and the call build their own

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 ea25cde725 Add a background without putting it on
- Whether choosing a file should also wear it is still open, so this does
  the smaller thing: the image joins the others and the user says when
- Reverts the auto-selection added earlier, which was never proven: the
  stories carry selection as a fixed arg, so nothing there could show it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 31e67577b0 Keep the refusal out of the scrolling list
- It sat inside the device list, which scrolls and is bounded by the size
  of the call, so the menu's own height cut it off
- Now below that list, where nothing can clip it or leave it under the
  fold: no way to tell someone something failed
- 16px from the menu on every side, a step of which the menu's own column
  gap supplies

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 72ad732771 Space the refusal evenly, and let it go when the menu does
- It sat flush against the tiles with twice that gap beneath; now 16px
  all round, the same as everything else in the menu keeps from the frame
- It can be dismissed, and it does not outlive the menu it appeared in.
  Before, it stayed until the next attempt: closing the chevron and
  opening it again showed a message about a file chosen long ago
- Checked: shown on refusal, gone when dismissed, shown again on the next
  refusal, gone on reopening

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 fb223bcc4b Say why a file cannot be used as a background
- The reason appears with the grid, where the user chose the file, using
  the same Alert the reaction menu already reports errors with
- A reason for each: not an image, animated, could not be opened, and a
  fallback for anything the store itself refuses
- Named one by one rather than built from the reason, so the extractor
  finds every string it has to translate
- The background in force is untouched, which is the other half of
  FR-021: being refused a file changes nothing
- Covered by a story, and checked end to end by offering a text file:
  the message appears, the menu stays open, nothing is kept

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 3ac42a735b Make the cross remove by mouse, not just by keyboard
- The cross now sits beside the tile rather than inside it. Inside, the
  menu reached the item before any handler on the cross, whichever phase
  it was in: the tile was chosen, became the one in force, and the cross
  was taken away before it had acted
- That is why the keyboard worked and the mouse did not, and why the
  static stories missed it: with selection fixed by an arg, nothing
  re-rendered and the click survived
- A story with live selection covers it now: the cross removes the tile
  and does not make it the one in force on the way out

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 e3aaad8615 Let a user remove a background they added
- A cross where the tick would be, on hovering one of their own images
- Not offered on the one in force: that is what they are wearing, and
  taking it away would leave them with nothing chosen (FR-025)
- Delete or Backspace on a focused tile does the same, announced through
  aria-keyshortcuts. The cross is drawn rather than focusable: a control
  inside a menu item is invalid, and the item is what the arrows walk
- Covered by a story: the one in force offers no removal, the others say
  Delete and answer to it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 943cae0129 Sit the grid evenly in the menu, and the headings closer to their rules
- The grid now stands the same distance from the menu's frame on every
  side: 16px left, right, bottom, and above from its rule. It was 16 at
  the sides but 12 above and 28 below, since its own bottom padding and
  the menu's were both in play
- A ruled heading sits closer to its rule than the menu's own spacing
  allows, which is set for a heading with nothing under it: 14.5px from
  the heading's text rather than 18.5px, and the same in both sections
- Scoped to headings that are ruled off, so the microphone menu keeps the
  spacing it was drawn with

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 79eb5c8606 Draw the active frame at one width on every tile
- Thinner: colour alone, at the width every tile already carries
- The same on an added background as on no effect. It was thickened with
  an inset shadow, which draws inside the padding box where an image
  tile's picture sits, so the picture covered it and image tiles wore a
  thinner frame than the rest

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 35f9893b0a Even up the space below the two section rules
- The tile grid added its own room on top of the rule's, so the tiles sat
  20px below their rule while the device rows sat 12px below theirs
- The rule sets that distance now, and it is the same in both sections

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 304373e735 Rule off under each heading in the camera menu
- A rule under "Camera" as well as under "Background effects"
- Both run edge to edge: a separator insets itself by default, which
  leaves it floating short of the menu's sides
- The divider that separated the two groups goes; the rules do that work
- Closer to what they name than a separator's own spacing allows, which
  is set for one dividing two sections
- Scoped to the camera menu: the microphone menu still divides its two
  sections instead, which is the design the audio menu was drawn to

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 1af968748b Keep the menu open when adding a background, and choose it
- The picker moved into the menu: a native file dialog takes the focus,
  which the menu read as a click elsewhere and closed behind it
- Close requests are ignored while the picker is up, and a cancelled
  pick releases that through the input's own `cancel` event, which React
  does not type
- Adding answers with the background it kept, so the footer can choose
  it straight away rather than asking the user to pick it twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 81fe5d355f Let a user add a background of their own
- Added backgrounds are kept on the device in IndexedDB; nothing uploads
  them and no identifier would let anything fetch one
- Refuses what cannot be used and reduces what is larger than we keep:
  a 4000x3000 photo became 1920x1440 and 238KB became 5KB
- Animation is read from the file, not guessed from its type: an
  animated WebP and a still one share a type, and a .png may be an APNG
- Does not crop. The pipeline already covers the camera's frame with
  whatever it is given, recomputed as the frame changes, so cropping on
  the way in would bake in one shape and lose the rest for good
- The add tile is withheld once four are kept, rather than failing
- Added backgrounds live in their own context, not in ProcessorState:
  the publisher has no use for them and every stub of it would carry them

Not done: removing an added background, which the design gives no
affordance for, and telling the user why a file was refused, which has no
surface in the menu yet. Both are logged for now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 8a7eb5c7fd Make the settings blur control agree with the camera menu
- Both now read and write one setting, so they cannot disagree
- An image background reads as blur off there; turning blur on replaces
  the image, which is what FR-022 and EC-011 ask for
- The new setting defaults to whatever blur the user already had, so
  nobody loses the blur they turned on before this existed
- Covers the derivation with tests: what the checkbox shows, what it
  writes, and the fallback when a stored background is one we no longer
  ship

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 6a6605c233 Let background effects run under the dev server
- nodePolyfills rewrote MediaPipe's WASM loader, which mentions `process`
- That loader is a classic script whose job is to set self.ModuleFactory;
  as an ES module it sets nothing, so the segmenter threw
  "ModuleFactory not set" and no effect could run under `pnpm dev`
- Its transform now skips that one directory
- Affects blur on main too, not only this feature: neither could be
  exercised locally before
- Production was never affected, since the loader is emitted as an asset
  there; build still green and the assets still emitted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 9179b1cccb Wire the background effects grid to the pipeline
- Footer view model exposes the chosen effect and a setter for it
- The footer names the catalogue: a view model holds no translated text
- The camera menu shows the grid in place of the blur toggle
- Shipped images are numbered, not named, since the art is a stand-in
- Withholding the setter is what renders the section disabled, as with
  the speaker section where no output can be chosen

Exploration for FEATURES_SPEC/2026-09_Background_Effects.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 e823fbc85b Show background effects as a grid in the camera menu
Adds the Background effects section from the design: no effect, blur, the
shipped images and a tile for adding your own, under the camera list.

The tiles are menuitemradio like the device rows above rather than a new
control, so the menu keeps one keyboard model throughout and the selection is
announced rather than only drawn. An image tile is the picture, filling the
tile, and its name goes to assistive technology alone; the add tile is a plus,
named the same way. The tick stands where the glyph would, carrying its own
ground on a picture so it reads against whatever is behind it.

Repurposing a menu item as a tile means overriding its row layout on three
axes, each found by measuring rather than by eye: it lays itself out as a grid
of chevron, icon and label, so flex-direction does nothing; it carries the gap
between them as a right margin on each, which pushes both off the tile's
centre when they are stacked; and it insets its own text sixteen pixels, so
the grid needs the same inset to line its tiles up with the heading above them.

Sized against the design file rather than judged: glyph and tick a quarter of
the tile wide, an ink gap of about a seventh of its height to the label, and
the tile's proportion carried by its padding. Rows are equal height, set by
the tallest tile — a fixed ratio clipped the label as soon as the menu was
narrow enough that the ratio could not hold it — and the columns carry a
minimum width so the menu grows wide enough for three tiles rather than
shrinking them to whatever the device names need.

The blur hatch is a stand-in: the design system has no blur icon, and the
design's own is an icon whose strokes shorten towards the corners. A uniform
hatch fills its box, so this one is drawn smaller and finer to carry the same
weight rather than the same size.

Where background processing is unavailable, no effect stays choosable: it
needs none and it is what the user has. The rest are disabled, and the images
lose their colour as well as their contrast, since a washed-out photograph
still reads as one you could pick.

The existing blur toggle stays for callers that pass no effects, so nothing
that renders this component has to change yet.

Exploration for FEATURES_SPEC/2026-09_Background_Effects.md. Not for merge: the
image tiles are stand-in gradients drawn in the story, and nothing wires the
grid to the pipeline yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:53 +02:00
fkwpandClaude Opus 5 b90f267009 Attach background effects through one switchable pipeline
Replace the blur-only processor with a single wrapper that switches between no
effect, blur and a background image in place. Switching in place rather than
rebuilding matters: destroy() resets the transformer's first-frame flag, and
that flag makes it emit one unprocessed frame when it next starts processing,
so a rebuild per change would leak a frame of the real background every time.

Attach the pipeline the first time an effect is chosen and keep it attached
afterwards, including at no effect. Measured against a production build, the
segmentation assets are about 11.4MB and take ~143ms to initialise, so always
attaching would charge that to every user on every camera-on call, including
the majority who never choose an effect. Keeping it attached once it is there
costs about 25us per frame, which is within noise of no pipeline at all.

Complete init() while moving it. The old subclass replaced the base class's
init wholesale and dropped its image loading and disabled-mode setup, which
went unnoticed while blur was the only effect and blurRadius was the only
option that mattered.

Keep loading the segmenter from our own bundle. The library's assetPaths option
resolves WASM through MediaPipe's FilesetResolver from a directory, which is
not the same thing, and its defaults reach jsDelivr and googleapis.

Exploration for FEATURES_SPEC/2026-09_Background_Effects.md. Not for merge:
the two shipped backgrounds are stand-in gradients and there is no picker yet,
recorded as S1 and S2 in the spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:01:53 +02:00
fkwpandClaude Opus 5 5fff49670b Expect the default output entry to carry no name
- 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>
2026-09-17 22:52:08 +02:00
fkwp 0633b24880 Merge remote-tracking branch 'origin/main' into fkwp/explore/quick-audio-menu 2026-09-17 22:50:39 +02:00
fkwpandClaude Opus 5 099950a100 Update the device menu to the latest design
- 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>
2026-09-17 22:43:48 +02:00
Timo 03f81bcd64 Merge pull request #4266 from toger5/toger5/memberId-based-on-matrixRTCMode
Make the memberId dependent on the matrixRTC mode.
2026-09-17 14:02:32 +02:00
Timo K. 9f6c8bb434 Make the memberId dependent on the matrixRTC mode.
This outherwise creates to-device messages with the uuid for members
that use the userId:deviceId memberId in the state event.
2026-09-17 13:44:25 +02:00
fkwp 194bfea287 Cover the speaker section sitting above the microphone section
- 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.
2026-09-17 11:56:31 +02:00
fkwp 4f8f71e861 Cover the device menu with stories and end-to-end specs
- 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.
2026-09-17 11:56:31 +02:00
fkwp d7e0444117 Write the mute button once instead of twice per kind
- 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.
2026-09-17 11:56:31 +02:00
fkwp a6e00895a0 Track the focus modality on the menu itself
- 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.
2026-09-17 11:56:31 +02:00
fkwp 8f3506782a Keep each section's heading in view while the list scrolls
- 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.
2026-09-17 11:56:31 +02:00
fkwp 8508d88714 Size the device list to the call as it changes
- 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.
2026-09-17 11:56:31 +02:00
fkwp ace7e947b4 Show a default speaker where the platform lists none
- 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.
2026-09-17 11:56:31 +02:00
fkwp 6c3012a40b Line the level meter up with the device rows
- 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.
2026-09-17 11:56:31 +02:00
fkwp 6c9550da0a Give the level meter one bar size at every width
- 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.
2026-09-17 11:56:31 +02:00
fkwp d8211c0c61 Own the microphone level in the state layer
- 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.
2026-09-17 11:56:25 +02:00
renovate[bot] f56c74adf9 Update ghcr.io/element-hq/element-web:develop Docker digest to 3993d53 (#4265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-17 00:51:42 +00:00
fkwp 0802649375 Show the focus ring for the keyboard alone, and fix the radio styling
- 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
2026-09-16 17:44:55 +02:00