mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -3,6 +3,7 @@
|
||||
"user_menu": "User menu"
|
||||
},
|
||||
"action": {
|
||||
"add_background_image": "Add image",
|
||||
"blur_background": "Blur background",
|
||||
"close": "Close",
|
||||
"copy_link": "Copy link",
|
||||
@@ -212,6 +213,7 @@
|
||||
"auto_gain_control_label": "Automatic gain control",
|
||||
"background_blur_header": "Background",
|
||||
"background_blur_label": "Blur the background of the video",
|
||||
"background_effects_header": "Background effects",
|
||||
"bitrate_label": "Bitrate",
|
||||
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
|
||||
"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-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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
SpinnerIcon,
|
||||
BlockIcon,
|
||||
PlusIcon,
|
||||
CheckCircleSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -47,6 +50,16 @@ export interface MenuOptions {
|
||||
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 {
|
||||
/**
|
||||
* The accessible name of the menu. Defaults to a translated name for the
|
||||
@@ -78,6 +91,21 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
onSelectOutput?: (id: string) => void;
|
||||
videoBlurToggleClick?: () => void;
|
||||
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.
|
||||
* 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,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
backgroundEffects,
|
||||
selectedBackgroundEffect,
|
||||
onSelectBackgroundEffect,
|
||||
onAddBackgroundImage,
|
||||
onSelect,
|
||||
}) => {
|
||||
// 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 (
|
||||
<div
|
||||
className={classNames({
|
||||
@@ -502,6 +629,26 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
)}
|
||||
</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>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
|
||||
Reference in New Issue
Block a user