Hold the camera menu at 296px, as design sets it

- A long device name now wraps instead of widening the menu over more of
  the picture the user is judging. Narrower only where the call area is,
  so it still fits inside it: 268px in a 300px call.
- The self-preview is always the sixteen by nine it is drawn at, 294 by
  166 inside the frame, and Safari's narrowed preview has nothing left to
  happen to.
- Tiles are thirds of the set width, about the 80px design draws, where
  their floor was there only to push the menu wider.
- Nothing else in the menu gets to set its width either: the picture
  sits over its box, and the notice and the refusal fit the menu rather
  than asking for their sentence on one line, which took it to 340px.
- The microphone menu is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-23 10:29:56 +02:00
co-authored by Claude Opus 5.5
parent 31f057726f
commit 8d95c76b8b
3 changed files with 89 additions and 25 deletions
@@ -46,6 +46,8 @@ Please see LICENSE in the repository root for full details.
component: the menu is portalled outside the root, so neither a container component: the menu is portalled outside the root, so neither a container
query nor a viewport unit describes the space it is allowed to fill. */ query nor a viewport unit describes the space it is allowed to fill. */
.deviceList { .deviceList {
/* The camera menu's width, set by the component; auto everywhere else. */
inline-size: var(--device-list-inline-size, auto);
overflow-y: auto; overflow-y: auto;
min-block-size: 0; min-block-size: 0;
max-block-size: var(--device-list-max-height); max-block-size: var(--device-list-max-height);
@@ -150,11 +152,11 @@ Please see LICENSE in the repository root for full details.
*/ */
.effectGrid { .effectGrid {
display: grid; display: grid;
/* A floor, not just a share. The menu sizes itself to its content, so /* Thirds of a menu whose width is set, so the tiles are the size design
dividing whatever width the device names happen to need makes the tiles draws them — about eighty pixels — whatever the device names are. Zero
narrower the shorter those names are; this makes the menu wide enough for rather than the content as the floor, so a long label wraps inside its
three proper tiles instead. */ tile instead of pushing the grid wider than the menu. */
grid-template-columns: repeat(3, minmax(84px, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
/* Every tile the same height, set by the tallest — the ones carrying a /* Every tile the same height, set by the tallest — the ones carrying a
label. Forcing a ratio instead clips the label as soon as the menu is label. Forcing a ratio instead clips the label as soon as the menu is
narrow enough that the ratio cannot hold it. */ narrow enough that the ratio cannot hold it. */
@@ -383,6 +385,9 @@ Please see LICENSE in the repository root for full details.
supplies the lower half, so only the upper half is set here. Without it the supplies the lower half, so only the upper half is set here. Without it the
message sits flush against the tiles with a gap twice its size underneath. */ message sits flush against the tiles with a gap twice its size underneath. */
.effectError { .effectError {
/* For the same reason as the notice below: a refusal's title is a sentence,
and would otherwise set the menu's width rather than fit inside it. */
contain: inline-size;
/* A step short of the margin it keeps at the sides: the menu is a column /* A step short of the margin it keeps at the sides: the menu is a column
with a gap of its own, which makes up the difference above. */ with a gap of its own, which makes up the difference above. */
padding: var(--cpd-space-3x) var(--cpd-space-4x) 0; padding: var(--cpd-space-3x) var(--cpd-space-4x) 0;
@@ -408,6 +413,11 @@ Please see LICENSE in the repository root for full details.
* before the menu opened and will be true after it closes. * before the menu opened and will be true after it closes.
*/ */
.effectNotice { .effectNotice {
/* Takes the width the menu has rather than lending it one. The menu is as
wide as its widest content, and a sentence asks for its whole length on
one line: without this the notice widened the camera menu from 296 to 340
pixels, past the width the grid and the preview are drawn for. */
contain: inline-size;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: var(--cpd-space-3x); gap: var(--cpd-space-3x);
@@ -436,12 +446,12 @@ Please see LICENSE in the repository root for full details.
* frame's corner, because a video paints on a layer of its own and would * frame's corner, because a video paints on a layer of its own and would
* otherwise draw over the outline the menu is framed with. * otherwise draw over the outline the menu is framed with.
* *
* A fixed height rather than a ratio: sixteen by nine at the menu's narrowest, * Sixteen by nine across the camera menu's fixed width, less its frame, and
* and wider as a long device name widens the menu, cropped rather than * written as a height rather than a ratio: with a ratio and a cap Safari kept
* letterboxed — a face cut at the edge reads as a self-view, a face in bars * the ratio by narrowing the box, and the preview stopped short of the menu's
* reads as a broken one. Not aspect-ratio with a height cap: once the cap * right edge. Cropped rather than letterboxed where the call is narrow enough
* holds, Safari keeps the ratio by narrowing the box instead of the picture, * to narrow the menu — a face cut at the edge reads as a self-view, a face in
* and the preview stops short of the menu's right edge. * bars reads as a broken one.
*/ */
.selfPreview { .selfPreview {
position: relative; position: relative;
@@ -453,7 +463,7 @@ Please see LICENSE in the repository root for full details.
); );
margin-inline: var(--cpd-border-width-1); margin-inline: var(--cpd-border-width-1);
margin-block-end: var(--cpd-space-2x); margin-block-end: var(--cpd-space-2x);
block-size: 176px; block-size: 166px;
overflow: hidden; overflow: hidden;
border-start-start-radius: calc( border-start-start-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1) var(--cpd-space-3x) - var(--cpd-border-width-1)
@@ -465,8 +475,14 @@ Please see LICENSE in the repository root for full details.
color: var(--cpd-color-icon-secondary); color: var(--cpd-color-icon-secondary);
} }
/* Laid over the box rather than in it. In flow, a sixteen-by-nine picture
166 pixels tall asks for 295 of width, and the menu, sized to its widest
content, grew by the pixel that asked for. Out of flow it only fills what
the menu's width already gives it. */
.selfPreview > video, .selfPreview > video,
.selfPreview > img { .selfPreview > img {
position: absolute;
inset: 0;
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
object-fit: cover; object-fit: cover;
@@ -96,6 +96,10 @@ const WithACallArea: FC<{ children: ReactNode; height?: number }> = ({
// which no real call does. Tall enough to leave the menu room to open // which no real call does. Tall enough to leave the menu room to open
// upward and still be wholly on screen in the story's frame. // upward and still be wholly on screen in the story's frame.
blockSize: height, blockSize: height,
// And a call's width, for the same reason: the camera menu narrows to
// fit a call narrower than itself, and the centred layout Storybook
// gives every story would otherwise shrink this to the button's width.
inlineSize: "100vw",
display: "flex", display: "flex",
alignItems: "flex-end", alignItems: "flex-end",
justifyContent: "center", justifyContent: "center",
@@ -927,6 +931,16 @@ export const BackgroundEffectsSlowInThisBrowser: Story = {
await expect(Math.round(frame.bottom - box.bottom)).toBe( await expect(Math.round(frame.bottom - box.bottom)).toBe(
Math.round(box.left - frame.left), Math.round(box.left - frame.left),
); );
// A sentence asks for its whole length on one line, and the menu is as
// wide as its widest content: the notice once widened the camera menu from
// its set width to 340. It fits the menu rather than setting it, and lines
// up with the tiles above it.
await expect(Math.round(frame.width)).toBe(296);
const tile = menu.querySelector(`.${styles.effectTile}`)!;
await expect(Math.round(tile.getBoundingClientRect().left)).toBe(
Math.round(box.left),
);
}, },
}; };
@@ -966,7 +980,7 @@ export const BackgroundEffectsWithPreview: Story = {
// And paid for out of the list's share: 60% of 720, less the preview. // And paid for out of the list's share: 60% of 720, less the preview.
await expect(getComputedStyle(list).maxBlockSize).toBe( await expect(getComputedStyle(list).maxBlockSize).toBe(
`${Math.round(720 * 0.6) - 176}px`, `${Math.round(720 * 0.6) - 166}px`,
); );
}, },
}; };
@@ -1169,13 +1183,13 @@ export const BackgroundEffectsWithALongDeviceName: Story = {
}; };
/** /**
* A long device name widens the menu past the width at which the preview is * A long device name, which once widened the menu over more of the picture the
* sixteen by nine. The preview still spans it: Safari, given a ratio and a * user is judging. The camera menu now holds its width and the name wraps, so
* height cap, kept the ratio by narrowing the box and left a strip of menu at * the preview is always the sixteen by nine it is drawn at — and the Safari
* its right. Checked here in Chromium, which never did — no test tier here * failure that came of a widened menu, the preview stopping short of its right
* runs WebKit — so this holds the requirement, not the engine. * edge, has nothing left to happen to.
*/ */
export const BackgroundEffectsWithPreviewInAWideMenu: Story = { export const BackgroundEffectsWithPreviewAndALongDeviceName: Story = {
args: { args: {
...BackgroundEffectsWithALongDeviceName.args, ...BackgroundEffectsWithALongDeviceName.args,
selfPreview: <img src={cameraStandIn} alt="" />, selfPreview: <img src={cameraStandIn} alt="" />,
@@ -1190,11 +1204,10 @@ export const BackgroundEffectsWithPreviewInAWideMenu: Story = {
const preview = menu.querySelector<HTMLElement>(`.${styles.selfPreview}`)!; const preview = menu.querySelector<HTMLElement>(`.${styles.selfPreview}`)!;
const frame = menu.getBoundingClientRect(); const frame = menu.getBoundingClientRect();
const box = preview.getBoundingClientRect(); const box = preview.getBoundingClientRect();
// Wider than the preview's sixteen-by-nine width, or this proves nothing. await expect(Math.round(frame.width)).toBe(296);
await expect(frame.width).toBeGreaterThan((176 * 16) / 9 + 8);
await expect(frame.right - box.right).toBeLessThanOrEqual(2); await expect(frame.right - box.right).toBeLessThanOrEqual(2);
await expect(box.left - frame.left).toBeLessThanOrEqual(2); await expect(box.left - frame.left).toBeLessThanOrEqual(2);
await expect(Math.round(box.height)).toBe(176); await expect(Math.round(box.height)).toBe(166);
}, },
}; };
+38 -3
View File
@@ -188,13 +188,27 @@ const MIN_LIST_HEIGHT = 160;
/** /**
* The most the self-preview may take, which is also what it costs the list. * The most the self-preview may take, which is also what it costs the list.
* *
* Its fixed height: sixteen by nine across the menu at its narrowest, and * Its fixed height: sixteen by nine across the camera menu's width, less the
* cropped wider as the menu widens, so it never costs more than this. The * frame, so it never costs more than this. The
* preview is paid for out of the list's share rather than on top of it, so the * preview is paid for out of the list's share rather than on top of it, so the
* menu is never taller for having one: where the list could not keep its floor * menu is never taller for having one: where the list could not keep its floor
* after paying, there is no preview at all. * after paying, there is no preview at all.
*/ */
const PREVIEW_BLOCK = 176; const PREVIEW_BLOCK = 166;
/**
* The camera menu's width, as design sets it.
*
* Fixed rather than sized to its content, so a long device name wraps instead
* of widening the menu over more of the picture the user is trying to judge,
* and so the self-preview is always the sixteen by nine it is drawn at. Less
* only where the call area itself is narrower — the menu has to stay inside
* it — which is why it is measured rather than written into the stylesheet.
*/
const CAMERA_MENU_WIDTH = 296;
/** Clear space kept between the menu and the call area's sides. */
const CAMERA_MENU_MARGIN = 16;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title, title,
@@ -319,6 +333,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
return (): void => subscription.unsubscribe(); return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]); }, [menuOpen, rootElement]);
const [cameraMenuWidth, setCameraMenuWidth] = useState(CAMERA_MENU_WIDTH);
useEffect(() => {
if (!menuOpen || iconsAndLabels !== "video") return;
const subscription = observeElementSize$(rootElement)
.pipe(
map(({ width }) =>
Math.min(
CAMERA_MENU_WIDTH,
Math.round(width - 2 * CAMERA_MENU_MARGIN),
),
),
distinctUntilChanged(),
)
.subscribe(setCameraMenuWidth);
return (): void => subscription.unsubscribe();
}, [menuOpen, iconsAndLabels, rootElement]);
// Only while the menu is open, so nothing holds a second capture of the // Only while the menu is open, so nothing holds a second capture of the
// microphone for the length of a call. // microphone for the length of a call.
// The meter sits over the foot of the scrolling list, so the list has to // The meter sits over the foot of the scrolling list, so the list has to
@@ -780,6 +811,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{ {
"--device-list-max-height": "--device-list-max-height":
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`, listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
// The menu takes its width from its content, and this is the
// content that could otherwise set it. Only the camera menu.
"--device-list-inline-size":
iconsAndLabels === "video" ? `${cameraMenuWidth}px` : undefined,
"--device-list-scroll-padding-end": "--device-list-scroll-padding-end":
meterHeight === undefined ? undefined : `${meterHeight}px`, meterHeight === undefined ? undefined : `${meterHeight}px`,
"--device-list-scroll-padding-start": "--device-list-scroll-padding-start":