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>
This commit is contained in:
fkwp
2026-09-17 23:01:53 +02:00
co-authored by Claude Opus 5
parent b90f267009
commit e823fbc85b
4 changed files with 461 additions and 0 deletions
+2
View File
@@ -3,6 +3,7 @@
"user_menu": "User menu" "user_menu": "User menu"
}, },
"action": { "action": {
"add_background_image": "Add image",
"blur_background": "Blur background", "blur_background": "Blur background",
"close": "Close", "close": "Close",
"copy_link": "Copy link", "copy_link": "Copy link",
@@ -212,6 +213,7 @@
"auto_gain_control_label": "Automatic gain control", "auto_gain_control_label": "Automatic gain control",
"background_blur_header": "Background", "background_blur_header": "Background",
"background_blur_label": "Blur the background of the video", "background_blur_label": "Blur the background of the video",
"background_effects_header": "Background effects",
"bitrate_label": "Bitrate", "bitrate_label": "Bitrate",
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)", "blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
"camera_header": "Camera quality", "camera_header": "Camera quality",
@@ -135,3 +135,176 @@ Please see LICENSE in the repository root for full details.
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused); outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2)); outline-offset: calc(-1 * var(--cpd-border-width-2));
} }
/*
* The Background effects grid.
*
* Three across matches the design and keeps a thumbnail readable at a glance;
* with no effect, blur, two shipped images and the add tile it also settles
* into a tidy three-by-three at the maximum number of images.
*
* minmax(0, 1fr) rather than 1fr: a grid track will not shrink below its
* content's minimum width by default, and a tile carrying a label is wider
* than a third of the menu, so plain 1fr lays out three full-width columns and
* clips two of them.
*/
.effectGrid {
display: grid;
/* A floor, not just a share. The menu sizes itself to its content, so
dividing whatever width the device names happen to need makes the tiles
narrower the shorter those names are; this makes the menu wide enough for
three proper tiles instead. */
grid-template-columns: repeat(3, minmax(84px, 1fr));
/* 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
narrow enough that the ratio cannot hold it. */
grid-auto-rows: 1fr;
gap: var(--cpd-space-2x);
/* The same inset a menu heading and a device row carry, so the tiles line
their left edge up with the "B" of the heading above them and with the
radio buttons above that. Matching the surrounding boxes instead puts the
tiles to the left of both, since a heading insets its own text. */
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-3x);
}
/*
* A menu item lays itself out as a grid of chevron, icon and label in a row.
* A tile centres its glyph with the label beneath instead, so this has to
* outweigh that rule rather than sit beside it.
*
* The tiles are a little wider than tall, as the design draws them, and hold
* that shape whatever the menu measures.
*/
.effectGrid .effectTile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
/* Glyph and label centre together as one group. Pinning the label to the
floor instead leaves the glyph riding high in the space above it. */
justify-content: center;
/* Measured against the design: the glyph sits a quarter of the tile wide,
with about a seventh of the tile's height between its ink and the label's.
The label's own leading is part of that distance, so the step here is
smaller than the ink gap it produces. */
gap: var(--cpd-space-1x);
block-size: auto;
min-block-size: 0;
min-inline-size: 0;
/* More room above and below than at the sides: the tile is wider than tall,
and the padding is what carries that proportion now the gap is set by the
design's spacing rather than by filling the box. */
padding: var(--cpd-space-3x) var(--cpd-space-2x);
overflow: hidden;
border: 2px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-2x);
}
/* The selection is drawn here and announced by the item's aria-checked, so it
never rests on colour alone. Matched to the specificity of the base tile
rule above, which also sets a border-color and would otherwise win.
The inset shadow thickens the frame without changing the tile's geometry,
which a wider border would, shifting every tile as the selection moves. */
.effectGrid .effectTileSelected {
border-color: var(--cpd-color-border-accent);
box-shadow: inset 0 0 0 var(--cpd-border-width-1)
var(--cpd-color-border-accent);
}
.effectGrid .effectTile > * {
/* A menu item spaces its icon and label for a row, with the gap carried as a
right margin on each. Stacked in a tile that margin is dead space on one
side, which shifts the glyph and the label off the tile's centre. */
margin: 0;
min-inline-size: 0;
max-inline-size: 100%;
text-align: center;
overflow-wrap: anywhere;
}
.effectSwatch {
flex: 0 0 auto;
inline-size: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--cpd-color-icon-secondary);
}
/* An image tile is the picture: it fills the tile rather than sitting in a box
inside one, which is what the design draws and what makes a background
recognisable at this size. */
.effectThumb {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* Blur has no icon in the design system, and a hatch reads as "softened" in a
way a glyph does not. */
.effectBlurGlyph {
/* Smaller than the glyph beside it, and than the design's own hatch measures.
The design's is an icon whose strokes shorten towards the corners, so it
leaves them empty and reads lighter; a uniform hatch fills its box and
reads heavier at the same size. */
inline-size: 18px;
block-size: 18px;
border-radius: var(--cpd-space-1x);
background-image: repeating-linear-gradient(
135deg,
var(--cpd-color-icon-secondary) 0 1.2px,
transparent 1.2px 3.15px
);
}
/* The tick marking the tile in force, standing where the glyph would.
Positioned, so it paints above the image, which is positioned itself and
would otherwise cover anything left in normal flow. */
.effectCheck {
position: relative;
color: var(--cpd-color-icon-accent-primary);
}
/* On a picture it needs its own ground: a tick alone disappears into whatever
the image happens to be behind it. */
.effectCheckOnImage {
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
box-shadow: 0 0 0 var(--cpd-border-width-2)
var(--cpd-color-bg-canvas-default);
}
/* A menu item's label is sized for a row; in a tile the design sets it a step
smaller, against the glyph above it. */
.effectLabel {
font: var(--cpd-font-body-sm-medium);
/* A menu item labels itself in the secondary colour, which reads as muted
under a glyph the design draws at full strength. */
color: var(--cpd-color-text-primary);
}
/* Named for assistive technology, not drawn: an image labels itself, and the
design gives the add tile a plus alone. */
.effectLabelUnseen {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/*
* Unavailable. Opacity alone is not enough on an image tile: a washed-out
* photograph still reads as a photograph you could pick. Draining the colour
* as well says plainly that there is nothing to choose here.
*/
.effectGrid .effectTile[aria-disabled="true"] {
opacity: 0.55;
}
.effectGrid .effectTile[aria-disabled="true"] .effectThumb {
filter: grayscale(1);
}
@@ -768,3 +768,142 @@ export const FocusRingCoversTheBlurToggle: Story = {
await expect(outlineWidth(toggle)).toBe(0); await expect(outlineWidth(toggle)).toBe(0);
}, },
}; };
/**
* Stand-in background art, drawn here rather than imported, so these stories
* carry no asset of their own. The shipped images do not exist yet.
*/
const swatch = (from: string, to: string): string =>
`data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="80" height="60">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="${from}"/>` +
`<stop offset="1" stop-color="${to}"/></linearGradient></defs>` +
`<rect width="80" height="60" fill="url(#g)"/></svg>`,
)}`;
const backgroundEffects = [
{ id: "none", label: "None", kind: "none" as const },
{ id: "blur", label: "Blur", kind: "blur" as const },
{
id: "indoor",
label: "Indoor",
kind: "image" as const,
imageUrl: swatch("#d8c9a8", "#8a6f4a"),
},
{
id: "outdoor",
label: "Outdoor",
kind: "image" as const,
imageUrl: swatch("#9fd0e8", "#2f6f4f"),
},
];
/**
* The camera menu's Background effects section: no effect, blur, the shipped
* images, and the tile for adding your own.
*/
export const BackgroundEffects: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: true,
options: [
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedOption: "1",
onSelect: fn(),
backgroundEffects,
selectedBackgroundEffect: "none",
onSelectBackgroundEffect: fn(),
onAddBackgroundImage: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const blur = await within(document.body).findByRole("menuitemradio", {
name: "Blur",
});
await userEvent.click(blur);
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
},
};
/** An image is in force, so the grid marks it rather than no effect. */
export const BackgroundImageChosen: Story = {
args: {
...BackgroundEffects.args,
selectedBackgroundEffect: "outdoor",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const chosen = await within(document.body).findByRole("menuitemradio", {
name: "Outdoor",
});
// The selection is announced, not only drawn.
await expect(chosen).toHaveAttribute("aria-checked", "true");
},
};
/**
* Where the browser or device cannot run background processing. The section
* keeps its shape and its tiles, and none of them can be chosen.
*/
export const BackgroundEffectsUnavailable: Story = {
args: {
...BackgroundEffects.args,
onSelectBackgroundEffect: undefined,
onAddBackgroundImage: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const blur = await within(document.body).findByRole("menuitemradio", {
name: "Blur",
});
await waitFor(() => expect(blur).toHaveAttribute("aria-disabled", "true"));
// No effect needs no background processing, so it stays choosable.
const none = await within(document.body).findByRole("menuitemradio", {
name: "None",
});
await expect(none).not.toHaveAttribute("aria-disabled", "true");
},
};
/**
* A device name long enough to set the menu's width, so the tiles are seen at
* the widest the menu gets rather than only at the narrowest.
*/
export const BackgroundEffectsWithALongDeviceName: Story = {
args: {
...BackgroundEffects.args,
options: [
{
label: {
type: "name",
name: "Logitech BRIO 4K Ultra HD Pro Business Webcam (046d:085e)",
},
id: "1",
},
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedOption: "1",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const none = await within(document.body).findByRole("menuitemradio", {
name: "None",
});
// Three tiles to a row however wide the name makes the menu.
const grid = none.parentElement!;
await expect(getComputedStyle(grid).gridTemplateColumns.split(" ")).toHaveLength(3);
},
};
+147
View File
@@ -25,6 +25,9 @@ import {
ChevronUpIcon, ChevronUpIcon,
ChevronDownIcon, ChevronDownIcon,
SpinnerIcon, SpinnerIcon,
BlockIcon,
PlusIcon,
CheckCircleSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -47,6 +50,16 @@ export interface MenuOptions {
id: string; id: string;
} }
/** One choice in the camera menu's Background effects section. */
export interface BackgroundEffectOption {
id: string;
/** Shown under the tile, and the item's accessible name. */
label: string;
kind: "none" | "blur" | "image";
/** The thumbnail, for kind "image". Blur and no effect draw their own. */
imageUrl?: string;
}
export interface MediaMuteAndSwitchButtonProps { export interface MediaMuteAndSwitchButtonProps {
/** /**
* The accessible name of the menu. Defaults to a translated name for the * The accessible name of the menu. Defaults to a translated name for the
@@ -78,6 +91,21 @@ export interface MediaMuteAndSwitchButtonProps {
onSelectOutput?: (id: string) => void; onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void; videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean; videoBlurEnabled?: boolean;
/**
* Background effects, shown as a grid of tiles under the camera list. Camera
* menu only; omitted entirely for audio. An empty list leaves the section out.
*/
backgroundEffects?: BackgroundEffectOption[];
/** The effect currently in force. */
selectedBackgroundEffect?: string;
/**
* Called when an effect is chosen. Undefined means no effect can be chosen
* here, and the section renders disabled, so the menu keeps the same shape
* wherever background processing is unavailable.
*/
onSelectBackgroundEffect?: (id: string) => void;
/** Called when the add tile is chosen. Omit to leave that tile out. */
onAddBackgroundImage?: () => void;
/** /**
* For any toggle and option this method will be called. * For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled` * So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -125,6 +153,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
onSelectOutput, onSelectOutput,
videoBlurEnabled, videoBlurEnabled,
videoBlurToggleClick, videoBlurToggleClick,
backgroundEffects,
selectedBackgroundEffect,
onSelectBackgroundEffect,
onAddBackgroundImage,
onSelect, onSelect,
}) => { }) => {
// Which device we have asked for but not yet been given. Carries the kind as // Which device we have asked for but not yet been given. Carries the kind as
@@ -409,6 +441,101 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
)); ));
}; };
// The camera menu's Background effects section. Tiles rather than rows, but
// menuitemradio like the device list above, so the menu stays one keyboard
// model throughout and the selection is announced rather than only drawn.
const effectTiles = (): ReactElement[] => {
const list = backgroundEffects ?? [];
// Shown but not choosable where background processing is unavailable, so
// the menu keeps the same shape on every platform. No effect is the
// exception: it needs no processing, so it stays choosable and stays the
// one in force.
const unavailable = onSelectBackgroundEffect === undefined;
const tiles = list.map((effect) => (
<MenuItem
as="div"
hideChevron
disabled={unavailable && effect.kind !== "none"}
className={classNames(styles.effectTile, {
[styles.effectTileSelected]: selectedBackgroundEffect === effect.id,
})}
label={effect.label}
// An image is its own label, so its name is carried for assistive
// technology alone rather than drawn over the picture.
labelProps={{
className:
effect.kind === "image"
? styles.effectLabelUnseen
: styles.effectLabel,
}}
Icon={
<span aria-hidden className={styles.effectSwatch}>
{effect.kind === "image" && (
<img
className={styles.effectThumb}
src={effect.imageUrl}
alt=""
/>
)}
{selectedBackgroundEffect === effect.id ? (
// The tick stands where the glyph would, and on a picture it
// carries its own ground so it reads against whatever is behind
// it.
<CheckCircleSolidIcon
className={classNames(styles.effectCheck, {
[styles.effectCheckOnImage]: effect.kind === "image",
})}
width={20}
height={20}
/>
) : (
<>
{effect.kind === "none" && <BlockIcon width={20} height={20} />}
{effect.kind === "blur" && (
<span className={styles.effectBlurGlyph} />
)}
</>
)}
</span>
}
onSelect={(e) => {
e.preventDefault();
if (effect.id === selectedBackgroundEffect) return;
onSelectBackgroundEffect?.(effect.id);
}}
key={effect.id}
role="menuitemradio"
aria-checked={selectedBackgroundEffect === effect.id}
/>
));
// Adding is a command, not a choice, so it is a plain item among the tiles
// rather than another radio.
if (onAddBackgroundImage !== undefined)
tiles.push(
<MenuItem
as="div"
hideChevron
disabled={unavailable}
className={styles.effectTile}
label={t("action.add_background_image")}
// The design draws a plus alone; the name is kept for assistive
// technology, which has nothing else to go on.
labelProps={{ className: styles.effectLabelUnseen }}
Icon={
<span aria-hidden className={styles.effectSwatch}>
<PlusIcon width={24} height={24} />
</span>
}
onSelect={(e) => {
e.preventDefault();
onAddBackgroundImage();
}}
key="add-background-image"
/>,
);
return tiles;
};
return ( return (
<div <div
className={classNames({ className={classNames({
@@ -502,6 +629,26 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
)} )}
</div> </div>
</div> </div>
{iconsAndLabels === "video" &&
backgroundEffects !== undefined &&
backgroundEffects.length > 0 && (
<>
<Separator />
<div
role="group"
aria-label={t("settings.background_effects_header")}
>
<div aria-hidden className={styles.sectionHeading}>
<MenuTitle
title={t("settings.background_effects_header")}
/>
</div>
<div role="none" className={styles.effectGrid}>
{effectTiles()}
</div>
</div>
</>
)}
</div> </div>
{toggles.length > 0 && <hr />} {toggles.length > 0 && <hr />}
{toggles.map((toggle) => ( {toggles.map((toggle) => (