From c1c88ca6239ea0fa929a2f4e7d2533cb42e6b180 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 5 Aug 2026 16:40:31 +0100 Subject: [PATCH 1/2] Add search and skin tones to profile emoji picker Signed-off-by: kenny lopez --- .../profile/ui/ProfileAvatarEditor.tsx | 10 ++- .../profile/ui/ProfileAvatarEditor.utils.ts | 48 +++++++++++- .../tests/e2e/onboarding-avatar-skip.spec.ts | 35 +++++++++ desktop/tests/e2e/profile.spec.ts | 74 +++++++++++++++++++ 4 files changed, 160 insertions(+), 7 deletions(-) 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..1aad4ea2de 100644 --- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts +++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts @@ -38,6 +38,41 @@ 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 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..25764c8e97 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -585,6 +585,80 @@ 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 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, }) => { From 1f8368d6612e0f5d7d51b9c8debe12a3dae569aa Mon Sep 17 00:00:00 2001 From: Honey <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 17:45:23 +0100 Subject: [PATCH 2/2] Wait for emoji picker overrides before asserting control styling The new picker assertions read overridden properties (48px control heights, the resting search background) immediately after the search input becomes visible. The Buzz stylesheet is appended from a `requestAnimationFrame` loop that only starts once `em-emoji-picker` has attached its shadow root, so it can land a frame or more later. An instrumented probe caught that gap: on 2 of 5 runs the shadow root had no `#buzz-emoji-mart-style` at that read point and the input measured Emoji Mart's un-overridden 36px instead of 48px. The shipped tests passed only because incidental round-trips bought enough time. `expect(...).toBe(48)` does not retry, so the race was live. Add `expectEmojiMartStylesInstalled` to the CSS helpers, polling for the style element, and gate both specs on it before their un-retried reads. The install loop itself predates this branch and is unchanged. Also documents the second probe finding, which needed no fix: the resting background samples as transparent right after install because Emoji Mart puts a 225ms `background-color` transition on the input. It settles to `rgb(229, 229, 230)`, and the override does win the cascade over the vendor `var(--em-color-border)` rule. Co-authored-by: kenny lopez Signed-off-by: kenny lopez --- .../tests/e2e/onboarding-avatar-skip.spec.ts | 2 ++ desktop/tests/e2e/profile.spec.ts | 2 ++ desktop/tests/helpers/css.ts | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts index 1aad4ea2de..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 = { @@ -51,6 +52,7 @@ test("avatar step shares the profile emoji picker controls", async ({ 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", diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 25764c8e97..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) { @@ -597,6 +598,7 @@ test("offers emoji search and skin-tone controls for profile avatars", async ({ 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", 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); +}