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.

<details>
<summary>File changes</summary>

**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.

</details>

## 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 <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-18 12:54:40 -07:00
committed by GitHub
parent 417eea2230
commit c442a90a17
7 changed files with 259 additions and 19 deletions

View File

@@ -894,6 +894,7 @@ export function AppShell() {
/>
) : null}
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}

View File

@@ -4,10 +4,11 @@ import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle";
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { SidebarInset } from "@/shared/ui/sidebar";
import { SidebarInset, useSidebar } from "@/shared/ui/sidebar";
type AppShellChannelSurfaceProps = {
children: React.ReactNode;
hasCommunityRail: boolean;
isHuddleRoom: boolean;
isHuddleRoomStarting: boolean;
mainInsetRef: React.RefObject<HTMLElement | 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 (
<MainInsetProvider mainInsetRef={mainInsetRef}>
<SidebarInset
@@ -28,6 +36,7 @@ export function AppShellChannelSurface({
className={cn(
"isolate z-0 min-h-0 min-w-0 overflow-hidden",
isHuddleRoom ? "bg-background" : "bg-sidebar",
hasCollapsedSidebarGutter && "pl-2",
)}
data-buzz-content-surface={isHuddleRoom ? true : undefined}
data-buzz-content-unframed={isHuddleRoom ? true : undefined}
@@ -35,6 +44,12 @@ export function AppShellChannelSurface({
data-buzz-shadow-viewport
style={chromeCssVarDefaults as React.CSSProperties}
>
{hasCollapsedSidebarGutter ? (
<div
className="absolute inset-y-0 left-0 w-2 bg-sidebar"
data-collapsed-content-gutter
/>
) : null}
{isHuddleRoom && !isHuddleRoomStarting ? <HuddleRoomHeader /> : null}
<BuzzTheme.ContentSurface terminal={terminal} unframed={isHuddleRoom}>
{isHuddleRoomStarting ? <HuddleStartingView /> : children}

View File

@@ -374,7 +374,7 @@ export function CommunityRail({
return (
<nav
aria-label="Communities"
className="relative z-0 flex w-14 shrink-0 flex-col items-center gap-2.5 overflow-y-auto bg-sidebar px-2.5 pb-5 pt-[calc(var(--buzz-top-chrome-height,40px)+7px)]"
className="relative z-20 flex w-14 shrink-0 flex-col items-center gap-2.5 overflow-y-auto bg-sidebar px-2.5 pb-5 pt-[calc(var(--buzz-top-chrome-height,40px)+7px)]"
data-testid="community-rail"
>
<DndContext

View File

@@ -24,7 +24,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width";
@@ -37,7 +36,8 @@ const SIDEBAR_WIDTH_MAX = 420;
const SIDEBAR_WIDTH_MOBILE = "288px";
const SIDEBAR_WIDTH_ICON = "48px";
const SIDEBAR_KEYBOARD_SHORTCUT = "s";
// Increases button hit areas on mobile without changing their layout.
const MOBILE_ACTION_HIT_AREA = "after:absolute after:-inset-2 after:md:hidden";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
@@ -52,7 +52,6 @@ type SidebarContextProps = {
setSidebarWidth: (width: number | ((width: number) => number)) => void;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
@@ -133,7 +132,6 @@ function readSidebarWidth() {
? clampSidebarWidth(storedWidth)
: SIDEBAR_WIDTH_DEFAULT;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
@@ -220,10 +218,8 @@ const SidebarProvider = React.forwardRef<
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
// Expose semantic state so Tailwind descendants can style both modes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
@@ -366,7 +362,7 @@ const Sidebar = React.forwardRef<
<div
className={cn(
"absolute inset-y-0 z-10 hidden h-full w-(--sidebar-width) transition-[left,right,width,visibility] duration-200 ease-linear md:flex",
"group-data-[resizing=true]:transition-none group-data-[collapsible=offcanvas]:pointer-events-none group-data-[collapsible=offcanvas]:invisible",
"group-data-[resizing=true]:transition-none group-data-[collapsible=offcanvas]:invisible",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -374,6 +370,7 @@ const Sidebar = React.forwardRef<
? "p-[8px] group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_18px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className,
"group-data-[collapsible=offcanvas]:pointer-events-none",
)}
{...props}
>
@@ -381,7 +378,12 @@ const Sidebar = React.forwardRef<
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
<div
className="flex h-full w-full origin-top flex-col transition-[opacity,scale,translate] duration-200 ease-linear motion-reduce:transition-none motion-reduce:duration-0 group-data-[collapsible=offcanvas]:translate-x-6 group-data-[collapsible=offcanvas]:scale-95 group-data-[collapsible=offcanvas]:opacity-0"
data-sidebar-transition-content
>
{children}
</div>
</div>
</div>
</div>
@@ -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",

View File

@@ -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<Record<string, unknown>>,
@@ -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 }) => {

View File

@@ -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<HTMLElement>(
'[data-sidebar="trigger"]',
);
const sidebarContent = document.querySelector<HTMLElement>(
"[data-sidebar-transition-content]",
);
if (!(rail instanceof HTMLElement) || !trigger || !sidebarContent) {
return null;
}
const transitionStarted = new Promise<void>((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` });
});
}

View File

@@ -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" });