diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
index d663850482..f16cf4bd71 100644
--- a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
+++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
@@ -753,7 +753,11 @@ export function ProfileAvatarEditor({
) : (
@@ -788,9 +792,9 @@ export function ProfileAvatarEditor({
applyEmojiAvatar(emoji.native, nextColor);
}}
previewPosition="none"
- searchPosition="none"
+ searchPosition="sticky"
set="native"
- skinTonePosition="none"
+ skinTonePosition="search"
theme={emojiPickerTheme}
/>
diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts
index c82ad26436..03b5e38026 100644
--- a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts
+++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts
@@ -72,6 +72,7 @@ const EMOJI_MART_SHADOW_CSS = `
#root {
--padding: var(--buzz-emoji-picker-padding, 16px);
+ --buzz-emoji-picker-search-control-height: 48px;
--sidebar-width: 0px;
display: flex;
flex-direction: column;
@@ -127,6 +128,47 @@ const EMOJI_MART_SHADOW_CSS = `
display: none;
}
+ /* Match the app's member-search controls: a distinct resting surface and
+ * border make both the emoji search and its adjacent skin-tone control easy
+ * to find before either receives focus. */
+ .search input[type="search"],
+ .search + .flex {
+ background-color: rgb(var(--em-rgb-input));
+ box-shadow: inset 0 0 0 1px rgba(var(--em-rgb-color), 0.16);
+ }
+
+ .search input[type="search"] {
+ border-radius: 12px;
+ height: var(--buzz-emoji-picker-search-control-height);
+ padding-bottom: 0;
+ padding-top: 0;
+ }
+
+ .search input[type="search"]:focus {
+ box-shadow: inset 0 0 0 1px rgb(var(--em-rgb-accent));
+ }
+
+ .search + .flex {
+ border-radius: 12px;
+ flex: 0 0 auto;
+ height: var(--buzz-emoji-picker-search-control-height) !important;
+ margin-left: 8px;
+ width: var(--buzz-emoji-picker-search-control-height) !important;
+ }
+
+ .skin-tone-button {
+ background-color: transparent !important;
+ border: 0 !important;
+ border-radius: 8px;
+ box-shadow: none !important;
+ height: calc(var(--buzz-emoji-picker-search-control-height) - 8px) !important;
+ width: calc(var(--buzz-emoji-picker-search-control-height) - 8px) !important;
+ }
+
+ .skin-tone-button[aria-selected] {
+ background-color: transparent !important;
+ }
+
.category button .background {
background-color: transparent;
transition: background-color var(--duration) var(--easing);
@@ -593,14 +635,12 @@ export function useEmojiMartThemeVars() {
const foreground = hslToRgbString(
styles.getPropertyValue("--foreground"),
);
- const background = hslToRgbString(
- styles.getPropertyValue("--background"),
- );
+ const input = hslToRgbString(styles.getPropertyValue("--input"));
setThemeVars({
"--buzz-emoji-picker-rgb-background": muted ?? "54, 58, 79",
"--buzz-emoji-picker-rgb-color": foreground ?? "245, 247, 255",
- "--buzz-emoji-picker-rgb-input": background ?? "47, 51, 68",
+ "--buzz-emoji-picker-rgb-input": input ?? "47, 51, 68",
} as React.CSSProperties);
};
diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
index 0ee08e2f01..38870199cf 100644
--- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
+++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
+import { expectEmojiMartStylesInstalled } from "../helpers/css";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
@@ -38,6 +39,42 @@ test("avatar step always shows Skip for now button without an error", async ({
});
});
+test("avatar step shares the profile emoji picker controls", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await page.getByRole("tab", { name: "Emoji" }).click();
+
+ const picker = page.locator("em-emoji-picker");
+ await expect(picker.locator("input[type='search']")).toBeVisible();
+ await expectEmojiMartStylesInstalled(picker);
+ await expect(page.getByTestId("onboarding-avatar-emoji-picker")).toHaveCSS(
+ "height",
+ "384px",
+ );
+
+ const controlHeights = await picker.evaluate((element) => {
+ const input = element.shadowRoot?.querySelector
(
+ 'input[type="search"]',
+ );
+ const toneControl =
+ element.shadowRoot?.querySelector(".search + .flex");
+ if (!input || !toneControl) {
+ throw new Error("Onboarding emoji picker controls did not render.");
+ }
+ return {
+ input: input.getBoundingClientRect().height,
+ tone: toneControl.getBoundingClientRect().height,
+ };
+ });
+ expect(controlHeights).toEqual({ input: 48, tone: 48 });
+});
+
test("avatar step skip button completes community profile setup", async ({
page,
}) => {
diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts
index eefdef1fdd..c5175516cd 100644
--- a/desktop/tests/e2e/profile.spec.ts
+++ b/desktop/tests/e2e/profile.spec.ts
@@ -5,6 +5,7 @@ import {
installMockBridge,
TEST_IDENTITIES,
} from "../helpers/bridge";
+import { expectEmojiMartStylesInstalled } from "../helpers/css";
import { openProfileMenu, openSettings } from "../helpers/settings";
async function expectHomeView(page: import("@playwright/test").Page) {
@@ -585,6 +586,81 @@ test("renders emoji avatars with a static background layer", async ({
);
});
+test("offers emoji search and skin-tone controls for profile avatars", async ({
+ page,
+}) => {
+ await page.goto("/");
+
+ await openSettings(page, "profile");
+ await page.getByTestId("profile-avatar-edit").click();
+ await page.getByRole("tab", { name: "Emoji" }).click();
+
+ const picker = page.locator("em-emoji-picker");
+ const searchInput = picker.locator("input[type='search']");
+ await expect(searchInput).toBeVisible();
+ await expectEmojiMartStylesInstalled(picker);
+ await expect(page.getByTestId("profile-avatar-emoji-picker")).toHaveCSS(
+ "height",
+ "384px",
+ );
+
+ // Emoji Mart renders the tone selector alongside its search field when
+ // `skinTonePosition` is `search`. Keeping it in the same shadow-DOM region
+ // ensures profile avatars match the regular in-app emoji picker.
+ const hasSkinToneControl = await picker.evaluate((element) => {
+ const controls = element.shadowRoot?.querySelectorAll("button") ?? [];
+ return Array.from(controls).some((button) =>
+ /skin tone/i.test(button.getAttribute("aria-label") ?? ""),
+ );
+ });
+ expect(hasSkinToneControl).toBe(true);
+
+ const controlColors = await picker.evaluate((element) => {
+ const root = element.shadowRoot?.querySelector("#root");
+ const input = element.shadowRoot?.querySelector(
+ 'input[type="search"]',
+ );
+ const toneControl =
+ element.shadowRoot?.querySelector(".search + .flex");
+ if (!root || !input || !toneControl) {
+ throw new Error("Profile emoji picker controls did not render.");
+ }
+ return {
+ input: getComputedStyle(input).backgroundColor,
+ picker: getComputedStyle(root).backgroundColor,
+ tone: getComputedStyle(toneControl).backgroundColor,
+ };
+ });
+ expect(controlColors.input).not.toBe(controlColors.picker);
+ expect(controlColors.tone).not.toBe(controlColors.picker);
+
+ const controlHeights = await picker.evaluate((element) => {
+ const input = element.shadowRoot?.querySelector(
+ 'input[type="search"]',
+ );
+ const toneControl =
+ element.shadowRoot?.querySelector(".search + .flex");
+ const toneButton =
+ element.shadowRoot?.querySelector(".skin-tone-button");
+ if (!input || !toneControl || !toneButton) {
+ throw new Error("Profile emoji picker controls did not render.");
+ }
+ input.focus();
+ return {
+ inputHeight: input.getBoundingClientRect().height,
+ inputShadow: getComputedStyle(input).boxShadow,
+ toneButtonBorder: getComputedStyle(toneButton).borderTopWidth,
+ toneButtonShadow: getComputedStyle(toneButton).boxShadow,
+ toneHeight: toneControl.getBoundingClientRect().height,
+ };
+ });
+ expect(controlHeights.inputHeight).toBe(48);
+ expect(controlHeights.toneHeight).toBe(48);
+ expect(controlHeights.inputShadow).toMatch(/inset$/);
+ expect(controlHeights.toneButtonBorder).toBe("0px");
+ expect(controlHeights.toneButtonShadow).toBe("none");
+});
+
test("reveals emoji background colors only after choosing an emoji", async ({
page,
}) => {
diff --git a/desktop/tests/helpers/css.ts b/desktop/tests/helpers/css.ts
index 0985fb7887..31b0ac036b 100644
--- a/desktop/tests/helpers/css.ts
+++ b/desktop/tests/helpers/css.ts
@@ -99,3 +99,23 @@ export async function expectSmoothCorners(
)
.toBe(true);
}
+
+/**
+ * Waits for the Buzz emoji-picker overrides to be present in the Emoji Mart
+ * shadow root.
+ *
+ * The stylesheet is appended from a `requestAnimationFrame` loop that starts
+ * only once the `em-emoji-picker` custom element has attached its shadow root,
+ * so it can land a frame or more after the search input becomes visible. Any
+ * assertion on an overridden property (control heights, resting background)
+ * must wait for this, otherwise it can read Emoji Mart's un-overridden defaults.
+ */
+export async function expectEmojiMartStylesInstalled(picker: Locator) {
+ await expect
+ .poll(async () =>
+ picker.evaluate((element) =>
+ Boolean(element.shadowRoot?.querySelector("#buzz-emoji-mart-style")),
+ ),
+ )
+ .toBe(true);
+}