diff --git a/src/components/MicrophoneLevelMeter.module.css b/src/components/MicrophoneLevelMeter.module.css
index d20e3cb8c..04135f94d 100644
--- a/src/components/MicrophoneLevelMeter.module.css
+++ b/src/components/MicrophoneLevelMeter.module.css
@@ -27,12 +27,28 @@ Please see LICENSE in the repository root for full details.
}
.segments {
- /* Spans the menu by spreading the gaps, so the bars keep their proportion
- instead of growing to fill the space. */
+ /* A bar and the space beside it are always the same size; what changes with
+ the width available is how many bars there are. Spreading a fixed number of
+ bars instead would make the meter a different shape in every place it is
+ used, and close them up into one solid block wherever the space ran short —
+ and a meter whose bars touch stops reading as a count, which is what
+ carries the level for anyone who cannot rely on the colour.
+
+ Both sizes are set here and nowhere else: the component reads them back off
+ the rendered bars to work out how many fit. */
flex: 1;
+ /* The width decides the count, never the other way round. Containment is
+ what enforces that: without it the bars are both a floor under the width
+ and the widest thing in the menu, so the meter would set the menu's width
+ and the device names — which are what a person is reading — would have to
+ fit around it. */
+ contain: inline-size;
+ min-inline-size: 0;
display: flex;
align-items: center;
- justify-content: space-between;
+ /* Half again as wide as a bar, as the design has it: bars set as close as
+ their own width read as a solid block long before they touch. */
+ gap: var(--cpd-space-1-5x);
}
.segment {
diff --git a/src/components/MicrophoneLevelMeter.stories.tsx b/src/components/MicrophoneLevelMeter.stories.tsx
new file mode 100644
index 000000000..b18a64c6e
--- /dev/null
+++ b/src/components/MicrophoneLevelMeter.stories.tsx
@@ -0,0 +1,178 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { expect, within } from "storybook/test";
+import { type JSX } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import {
+ MicrophoneLevelMeter,
+ type MicrophoneLevelMeterProps,
+} from "./MicrophoneLevelMeter";
+import styles from "./MicrophoneLevelMeter.module.css";
+import { METER_SEGMENTS } from "../state/MicrophoneLevel";
+
+/**
+ * A width to show the meter at, close to the menu it lives in.
+ *
+ * Not a copy of the menu's width, and nothing depends on the two agreeing: a
+ * bar and the gap beside it are a fixed size now, so this only decides how many
+ * bars there is room for. Without a width at all the stories would shrink-wrap
+ * to almost nothing and show a meter two bars wide, which is no use to anyone
+ * looking at them.
+ */
+const STORY_WIDTH = 256;
+
+const meta = {
+ component: MicrophoneLevelMeter,
+ decorators: [
+ (Story): JSX.Element => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ state: {
+ description:
+ "What the selected microphone can say about itself: a level, or a reason there is none.",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/**
+ * A quiet room. Nothing is lit: room hiss below the noise floor must not read
+ * as "it can hear me".
+ */
+export const Silent: Story = {
+ args: { state: { type: "level", level: 0 } },
+ play: async ({ canvasElement }) => {
+ await expect(litSegments(canvasElement)).toBe(0);
+ },
+};
+
+export const QuietSpeech: Story = {
+ args: { state: { type: "level", level: 5 } },
+};
+
+export const NormalSpeech: Story = {
+ args: { state: { type: "level", level: 12 } },
+ play: async ({ canvasElement }) => {
+ // Shown at something like the width of the menu, so the meter in a story
+ // reads like the meter in a call rather than like a handful of bars. The
+ // design's own mock has sixteen of them at this width; a floor rather than
+ // a count, because the number follows from the bar and gap sizes and those
+ // are the design's to change.
+ await expect(
+ canvasElement.getElementsByClassName(styles.segment).length,
+ ).toBeGreaterThanOrEqual(15);
+ },
+};
+
+export const LoudSpeech: Story = {
+ args: { state: { type: "level", level: METER_SEGMENTS } },
+};
+
+/**
+ * The three volumes differ by how many bars are lit, so the level survives
+ * greyscale and a screen reader as well as it survives colour.
+ */
+export const VolumesAreDistinguishable: Story = {
+ args: { state: { type: "level", level: 5 } },
+ play: async ({ canvasElement, mount }) => {
+ const lit: number[] = [];
+ for (const level of [5, 12, METER_SEGMENTS]) {
+ await mount();
+ lit.push(litSegments(canvasElement));
+ await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
+ "aria-valuenow",
+ String(level),
+ );
+ }
+ // Three different counts, rising: the level is carried by how many bars
+ // are lit, not by their colour alone.
+ await expect(new Set(lit).size).toBe(lit.length);
+ await expect(lit).toEqual([...lit].sort((a, b) => a - b));
+ },
+};
+
+/**
+ * Permission refused. A message with a next action, never a still meter that
+ * reads as silence.
+ */
+export const PermissionDenied: Story = {
+ args: { state: { type: "permission-denied" } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.queryByRole("meter")).toBeNull();
+ await expect(
+ canvas.getByText(/Allow access in your browser settings/),
+ ).toBeVisible();
+ },
+};
+
+/** No input device at all, told apart from a refusal. */
+export const NoDevice: Story = {
+ args: { state: { type: "no-device" } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.queryByRole("meter")).toBeNull();
+ await expect(canvas.getByText(/No microphone found/)).toBeVisible();
+ },
+};
+
+/**
+ * The same meter at two widths.
+ *
+ * A bar and the space beside it are always the same size; what changes is how
+ * many bars there are. Spreading a fixed number of bars instead would make the
+ * meter a different shape in every place it is used, and close the bars up into
+ * one block wherever the space ran short — and bars that touch cannot be
+ * counted, which is what carries the level without colour.
+ */
+export const ShapeStaysTheSameAtAnyWidth: Story = {
+ args: { state: { type: "level", level: 12 } },
+ play: async ({ mount, args }) => {
+ const narrow = await measureAt(mount, args, 180);
+ const wide = await measureAt(mount, args, 400);
+
+ await expect(narrow.bar).toBe(wide.bar);
+ await expect(narrow.gap).toBe(wide.gap);
+ await expect(narrow.count).toBeLessThan(wide.count);
+ // And the bars are still bars, not one run of colour.
+ await expect(narrow.gap).toBeGreaterThan(0);
+ },
+};
+
+/** How many bars are painted as carrying level, rather than as empty. */
+function litSegments(canvasElement: HTMLElement): number {
+ return canvasElement.getElementsByClassName(styles.segmentLit).length;
+}
+
+/** Renders the meter at one width and reports the shape of its bars. */
+async function measureAt(
+ mount: (ui: JSX.Element) => Promise,
+ args: MicrophoneLevelMeterProps,
+ width: number,
+): Promise<{ count: number; bar: number; gap: number }> {
+ await mount(
+
+
+
,
+ );
+ const bars = Array.from(document.body.getElementsByClassName(styles.segment));
+ const first = bars[0].getBoundingClientRect();
+ const second = bars[1].getBoundingClientRect();
+ return {
+ count: bars.length,
+ bar: first.width,
+ gap: second.left - first.right,
+ };
+}
diff --git a/src/components/MicrophoneLevelMeter.tsx b/src/components/MicrophoneLevelMeter.tsx
index c0c36c930..ec5c823a2 100644
--- a/src/components/MicrophoneLevelMeter.tsx
+++ b/src/components/MicrophoneLevelMeter.tsx
@@ -5,16 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
-import { type FC } from "react";
+import { useCallback, useState, type FC } from "react";
import { Text } from "@vector-im/compound-web";
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
+import { distinctUntilChanged, map } from "rxjs";
+
import styles from "./MicrophoneLevelMeter.module.css";
import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
+import { observeElementSize$ } from "../utils/elementSize";
-interface Props {
+export interface MicrophoneLevelMeterProps {
state: MicrophoneState;
className?: string;
}
@@ -26,8 +29,29 @@ interface Props {
* as whether the user is being heard: it keeps moving while muted, and the mute
* control is what says nothing is transmitted.
*/
-export const MicrophoneLevelMeter: FC = ({ state, className }) => {
+export const MicrophoneLevelMeter: FC = ({
+ state,
+ className,
+}) => {
const { t } = useTranslation();
+ // How many bars there is room for. The bars never change size, so this is
+ // what absorbs a change of width. Starts at the full count so that the first
+ // paint is a meter rather than a single bar, and so that a renderer with no
+ // layout at all — jsdom — still draws the whole thing.
+ const [barCount, setBarCount] = useState(METER_SEGMENTS);
+ const track = useCallback(
+ (element: HTMLDivElement | null): (() => void) | undefined => {
+ if (element === null) return;
+ const subscription = observeElementSize$(element)
+ .pipe(
+ map(({ width }) => barsThatFit(element, width)),
+ distinctUntilChanged(),
+ )
+ .subscribe(setBarCount);
+ return (): void => subscription.unsubscribe();
+ },
+ [],
+ );
if (state.type !== "level")
return (
@@ -45,6 +69,7 @@ export const MicrophoneLevelMeter: FC = ({ state, className }) => {
= ({ state, className }) => {
max: METER_SEGMENTS,
})}
>
- {Array.from({ length: METER_SEGMENTS }, (_, i) => (
+ {Array.from({ length: barCount }, (_, i) => (
))}
@@ -69,3 +97,18 @@ export const MicrophoneLevelMeter: FC
= ({ state, className }) => {
);
};
+
+/**
+ * How many bars fit across `width`, at the size the stylesheet draws them.
+ *
+ * Measured off a rendered bar rather than told: the size of a bar and of the
+ * space beside it are a design question, settled in the stylesheet, and reading
+ * them back is what keeps them from being settled twice.
+ */
+function barsThatFit(track: HTMLElement, width: number): number {
+ const gap = Number.parseFloat(getComputedStyle(track).columnGap);
+ const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
+ // A renderer that lays nothing out tells us nothing; keep the full count.
+ if (!(bar > 0) || !(gap >= 0)) return METER_SEGMENTS;
+ return Math.max(1, Math.floor((width + gap) / (bar + gap)));
+}