From c442a90a176845e3989436f2bb24eb6d0ca79d47 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 12:54:40 -0700 Subject: [PATCH] fix(desktop-chrome): preserve balanced layout when sidebar collapses (#6000) **Category:** fix **User Impact:** The desktop content surface now keeps balanced chrome spacing when the sidebar is collapsed or multiple communities are visible, with a cleaner sidebar transition. **Problem:** Collapsing the left sidebar removed the content surface's left inset, while multi-community layouts also exposed uneven visible gutters and sidebar content during the exit transition. **Solution:** Preserve an 8px visible gutter around the content surface, clip and fade only the sidebar's inner content while it trails off canvas, and leave the opaque shell in place until the existing visibility transition completes.
File changes **desktop/src/app/BuzzThemeSurfaces.tsx** Preserves a balanced visible left gutter after accounting for the content-edge shadow. **desktop/src/shared/ui/sidebar.tsx** Clips the collapsing sidebar, disables interaction off canvas, and fades/translates its inner content without exposing a different background. **desktop/tests/e2e/community-rail.spec.ts** Adds gutter assertions for single-community collapsed-sidebar and multi-community layouts. **desktop/tests/e2e/sidebar.spec.ts** Covers the collapse opacity, translation, clipping, pointer-event behavior, and restoration on reopen.
## Reproduction steps 1. Launch the desktop app with the Buzz theme and at least two communities. 2. Collapse the channel sidebar. 3. Confirm the main content surface keeps equal visible left and right gutters and does not overlap the community rail. 4. Reopen and collapse the sidebar again; confirm its contents fade and trail right while the chrome background stays opaque, then restore fully when reopened. ## Screenshots | Single community | Multiple communities | | --- | --- | | ![Sidebar collapse with a single community](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6000/multiple-communities-collapse.gif) | ![Sidebar collapse with multiple communities](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6000/single-community-collapse.gif) | ## Validation - `pnpm build:e2e` - focused sidebar integration tests: 2 passed - collapsed multi-community smoke regression: 1 passed - Biome check on all four changed files - pre-push desktop check, typecheck, and test hooks - `git diff --check` Related issue/PR: none found. --------- Signed-off-by: Taylor Ho --- desktop/src/app/AppShell.tsx | 1 + desktop/src/app/AppShellChannelSurface.tsx | 17 +++- .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- desktop/src/shared/ui/sidebar.tsx | 26 +++--- desktop/tests/e2e/community-rail.spec.ts | 75 +++++++++++++++- .../tests/e2e/sidebar-offcanvas-rail.spec.ts | 88 ++++++++++++++++++- desktop/tests/e2e/sidebar.spec.ts | 69 +++++++++++++++ 7 files changed, 259 insertions(+), 19 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b72..96aa7acbac 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -894,6 +894,7 @@ export function AppShell() { /> ) : null} ; @@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = { export function AppShellChannelSurface({ children, + hasCommunityRail, isHuddleRoom, isHuddleRoomStarting, mainInsetRef, terminal, }: AppShellChannelSurfaceProps) { + const { isMobile, openMobile, state: sidebarState } = useSidebar(); + const hasCollapsedSidebarGutter = + !isHuddleRoom && + !hasCommunityRail && + (isMobile ? !openMobile : sidebarState === "collapsed"); + return ( + {hasCollapsedSidebarGutter ? ( +
+ ) : null} {isHuddleRoom && !isHuddleRoomStarting ? : null} {isHuddleRoomStarting ? : children} diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 5394065b19..12cb2ea9d3 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -374,7 +374,7 @@ export function CommunityRail({ return (
@@ -707,8 +709,7 @@ const SidebarGroupAction = React.forwardRef< data-sidebar="group-action" className={cn( "absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", + MOBILE_ACTION_HIT_AREA, "group-data-[collapsible=icon]:hidden", className, )} @@ -853,8 +854,7 @@ const SidebarMenuAction = React.forwardRef< data-sidebar="menu-action" className={cn( "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", + MOBILE_ACTION_HIT_AREA, "peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=lg]/menu-button:top-2.5", diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 3f6d38602a..8d24ef7e1d 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge"; import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; const RELAY_URL = "ws://localhost:3000"; +const THEME_STORAGE_KEY = "buzz-theme"; const OWNER_PUBKEY = "deadbeef".repeat(8); function snapshotKey(relayUrl: string) { @@ -23,6 +24,25 @@ const COMMUNITY_B = { addedAt: "2026-01-02T00:00:00.000Z", }; +async function expectContentSurfaceHorizontalGutters( + page: import("@playwright/test").Page, + expectedLeftGutter = 1, +) { + const [mainInsetBox, contentBox] = await Promise.all([ + page.locator("[data-buzz-glass-inset]").boundingBox(), + page.locator("[data-buzz-content-surface]").first().boundingBox(), + ]); + expect(mainInsetBox).not.toBeNull(); + expect(contentBox).not.toBeNull(); + const leftGutter = (contentBox?.x ?? 0) - (mainInsetBox?.x ?? 0); + const rightGutter = + (mainInsetBox?.x ?? 0) + + (mainInsetBox?.width ?? 0) - + ((contentBox?.x ?? 0) + (contentBox?.width ?? 0)); + expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5); + expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5); +} + async function seedCommunities( page: import("@playwright/test").Page, communities: Array>, @@ -64,7 +84,7 @@ test.describe("community rail", () => { "overflow", "visible", ); - await expect(rail).toHaveCSS("z-index", "0"); + await expect(rail).toHaveCSS("z-index", "20"); const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); @@ -137,6 +157,7 @@ test.describe("community rail", () => { // The add-community affordance lives at the bottom of the rail. await expect(page.getByTestId("community-rail-add")).toBeVisible(); + await expectContentSurfaceHorizontalGutters(page); }); test("restores pointer events after dismissing community settings", async ({ @@ -1133,7 +1154,36 @@ test.describe("community rail", () => { ).toBeVisible(); }); + test("keeps the gutter when the mobile sidebar closes without a rail", async ({ + page, + }) => { + await page.setViewportSize({ width: 740, height: 516 }); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await expect( + page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), + ).toBeHidden(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "width", + "8px", + ); + await expectContentSurfaceHorizontalGutters(page, 9); + }); + test("hides the rail with a single community", async ({ page }) => { + await page.addInitScript((themeStorageKey) => { + window.localStorage.setItem(themeStorageKey, "buzz-dark"); + }, THEME_STORAGE_KEY); await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); await page.goto("/"); @@ -1142,6 +1192,25 @@ test.describe("community rail", () => { // adds nothing). await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect(page.getByTestId("community-rail")).toHaveCount(0); + + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.locator('[data-side="left"][data-state="collapsed"]'), + ).toBeVisible(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "width", + "8px", + ); + const sidebarBackground = await page + .locator("[data-buzz-glass-inset]") + .evaluate((element) => getComputedStyle(element).backgroundColor); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "background-color", + sidebarBackground, + ); + await expectContentSurfaceHorizontalGutters(page, 9); }); test("keeps the rail visible when the sidebar is collapsed", async ({ @@ -1174,6 +1243,10 @@ test.describe("community rail", () => { page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), ).toBeVisible(); await expect(page.getByTestId("community-rail-add")).toBeVisible(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCount( + 0, + ); + await expectContentSurfaceHorizontalGutters(page); }); test("clears the macOS traffic lights", async ({ page }) => { diff --git a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts index 8bee8da659..df94afc699 100644 --- a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts +++ b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; const SHOTS = "test-results/sidebar-offcanvas-rail"; const THEME_STORAGE_KEY = "buzz-theme"; @@ -20,7 +21,7 @@ const COMMUNITY_B = { }; async function setup(page: Page, theme: string) { - await page.setViewportSize({ width: 1280, height: 800 }); + await page.setViewportSize({ width: 960, height: 540 }); await page.addInitScript( ({ key, value }) => { window.localStorage.setItem(key, value); @@ -52,15 +53,95 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) { page, }) => { await setup(page, theme); + await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/${theme}-expanded.png` }); - await page.locator('[data-sidebar="trigger"]').first().click(); + const communityRail = page.getByTestId("community-rail"); + const communityButton = page.getByTestId( + `community-rail-button-${COMMUNITY_B.id}`, + ); + const railBoxBeforeCollapse = await communityRail.boundingBox(); + expect(railBoxBeforeCollapse).not.toBeNull(); + + // Observe the transition before triggering it, then hold every animated + // sidebar-content property at its midpoint. This keeps the regression + // causal without making its assertions depend on Playwright or rAF + // scheduler latency. + const transition = await communityButton.evaluate(async (button) => { + const rail = button.closest('[data-testid="community-rail"]'); + const trigger = document.querySelector( + '[data-sidebar="trigger"]', + ); + const sidebarContent = document.querySelector( + "[data-sidebar-transition-content]", + ); + if (!(rail instanceof HTMLElement) || !trigger || !sidebarContent) { + return null; + } + + const transitionStarted = new Promise((resolve) => { + sidebarContent.addEventListener("transitionrun", () => resolve(), { + once: true, + }); + }); + trigger.click(); + await transitionStarted; + + const animations = sidebarContent.getAnimations(); + await Promise.all(animations.map((animation) => animation.ready)); + for (const animation of animations) { + animation.pause(); + animation.currentTime = 100; + } + + const buttonBox = button.getBoundingClientRect(); + const railBox = rail.getBoundingClientRect(); + const hit = document.elementFromPoint( + buttonBox.x + buttonBox.width / 2, + buttonBox.y + buttonBox.height / 2, + ); + const railStyle = getComputedStyle(rail); + const sidebarStyle = getComputedStyle(sidebarContent); + const result = { + durations: animations.map( + (animation) => animation.effect?.getTiming().duration, + ), + hitRail: hit === rail || rail.contains(hit), + opacity: railStyle.opacity, + sidebarOpacity: Number.parseFloat(sidebarStyle.opacity), + sidebarScale: sidebarStyle.scale, + sidebarTranslateX: Number.parseFloat(sidebarStyle.translate), + visibility: railStyle.visibility, + x: railBox.x, + y: railBox.y, + }; + + for (const animation of animations) animation.finish(); + return result; + }); + expect(transition).not.toBeNull(); + expect(transition?.durations).toEqual([200, 200, 200]); + expect(transition).toMatchObject({ + hitRail: true, + opacity: "1", + visibility: "visible", + x: railBoxBeforeCollapse?.x, + y: railBoxBeforeCollapse?.y, + }); + expect(transition?.sidebarOpacity).toBeGreaterThan(0); + expect(transition?.sidebarOpacity).toBeLessThan(1); + expect(transition?.sidebarScale).not.toBe("none"); + expect(transition?.sidebarScale).not.toBe("0.95"); + expect(transition?.sidebarTranslateX).toBeGreaterThan(0); + expect(transition?.sidebarTranslateX).toBeLessThan(24); + const shell = page.locator( '[data-state="collapsed"][data-collapsible="offcanvas"]', ); await expect(shell).toHaveCount(1); + // Let the 200ms slide finish; visibility flips at the transition's end. - await page.waitForTimeout(500); + await page.waitForTimeout(250); // Second direct child = the sliding sidebar container (first is the gap). const offscreenSidebar = shell.locator("> div").nth(1); @@ -72,6 +153,7 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) { await expect( page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), ).toBeVisible(); + await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/${theme}-collapsed.png` }); }); } diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 7419499baf..01d9d64d67 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -531,6 +531,75 @@ test("aligns the sidebar search with the channel title outside the Buzz theme", expect(Math.abs(searchCenter - channelTitleCenter)).toBeLessThanOrEqual(2); }); +test("scales the sidebar backward while its chrome closes", async ({ + page, +}) => { + await page.goto("/"); + + const sidebar = page.getByTestId("app-sidebar"); + const sidebarSurface = sidebar.locator("[data-sidebar-transition-content]"); + await expect(sidebarSurface).toHaveCSS("opacity", "1"); + await expect(sidebarSurface).toHaveCSS("scale", "none"); + + await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + + await expect(sidebarSurface).toHaveCSS("opacity", "0"); + await expect(sidebar).toHaveCSS("pointer-events", "none"); + await expect(sidebar).toHaveCSS("overflow", "visible"); + await expect(sidebar.locator(':scope > [data-sidebar="sidebar"]')).toHaveCSS( + "background-color", + await sidebarSurface.evaluate((element) => { + const sidebarElement = element.closest('[data-sidebar="sidebar"]'); + if (!(sidebarElement instanceof HTMLElement)) return ""; + return getComputedStyle(sidebarElement).backgroundColor; + }), + ); + await expect(sidebarSurface).toHaveCSS("scale", "0.95"); + await expect(sidebarSurface).toHaveCSS("translate", "24px"); + const transformOrigin = await sidebarSurface.evaluate( + (element) => getComputedStyle(element).transformOrigin, + ); + const [originX, originY] = transformOrigin.split(" ").map(Number.parseFloat); + const surfaceWidth = await sidebarSurface.evaluate( + (element) => element.clientWidth, + ); + expect(Math.abs(originX - surfaceWidth / 2)).toBeLessThan(0.5); + expect(originY).toBe(0); + await expect(sidebarSurface).toHaveCSS( + "transition-property", + "opacity, scale, translate", + ); + await expect(sidebarSurface).toHaveCSS("transition-duration", "0.2s"); + await expect(sidebarSurface).toHaveCSS( + "transition-timing-function", + "linear", + ); + + await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + await expect(sidebarSurface).toHaveCSS("opacity", "1"); + await expect(sidebar).toHaveCSS("pointer-events", "auto"); + await expect(sidebarSurface).toHaveCSS("scale", "none"); +}); + +test("disables the sidebar collapse transition for reduced motion", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + + const sidebarSurface = page + .getByTestId("app-sidebar") + .locator("[data-sidebar-transition-content]"); + await expect(sidebarSurface).toHaveCSS("transition-duration", "0s"); + + await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + + await expect(sidebarSurface).toHaveCSS("opacity", "0"); + await expect(sidebarSurface).toHaveCSS("scale", "0.95"); + await expect(sidebarSurface).toHaveCSS("translate", "24px"); + await expect(sidebarSurface).toHaveCSS("transition-duration", "0s"); +}); + test("sidebar rail resizes without toggling the sidebar", async ({ page }) => { await page.goto("/"); const rail = page.getByRole("button", { name: "Resize sidebar" });