mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
**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 | | --- | --- | |  |  | ## 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>
160 lines
5.9 KiB
TypeScript
160 lines
5.9 KiB
TypeScript
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";
|
|
const RELAY_URL = "ws://localhost:3000";
|
|
|
|
const COMMUNITY_A = {
|
|
id: "ws-a",
|
|
name: "Alpha",
|
|
relayUrl: RELAY_URL,
|
|
addedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
const COMMUNITY_B = {
|
|
id: "ws-b",
|
|
name: "Bravo",
|
|
relayUrl: "ws://localhost:3001",
|
|
addedAt: "2026-01-02T00:00:00.000Z",
|
|
};
|
|
|
|
async function setup(page: Page, theme: string) {
|
|
await page.setViewportSize({ width: 960, height: 540 });
|
|
await page.addInitScript(
|
|
({ key, value }) => {
|
|
window.localStorage.setItem(key, value);
|
|
},
|
|
{ key: THEME_STORAGE_KEY, value: theme },
|
|
);
|
|
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
|
await page.addInitScript(
|
|
({ list, active }) => {
|
|
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
|
window.localStorage.setItem("buzz-active-community-id", active);
|
|
},
|
|
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
|
|
);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByTestId("community-rail")).toBeVisible();
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Regression: the app-sidebar layer is overflow-visible (huddle drawer), so
|
|
* the offcanvas-collapsed sidebar slides out of its container but kept
|
|
* painting over the community rail — opaquely on flat themes, as ghost
|
|
* fragments on the transparent Buzz chrome. The collapsed sidebar must be
|
|
* invisible and non-interactive, leaving the rail clean in every theme.
|
|
*/
|
|
for (const theme of ["buzz", "buzz-dark", "vesper"]) {
|
|
test(`collapsed sidebar leaves the community rail clean — ${theme}`, async ({
|
|
page,
|
|
}) => {
|
|
await setup(page, theme);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: `${SHOTS}/${theme}-expanded.png` });
|
|
|
|
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(250);
|
|
|
|
// Second direct child = the sliding sidebar container (first is the gap).
|
|
const offscreenSidebar = shell.locator("> div").nth(1);
|
|
await expect(offscreenSidebar).toHaveCSS("visibility", "hidden");
|
|
await expect(offscreenSidebar).toHaveCSS("pointer-events", "none");
|
|
|
|
// The community rail stays visible and interactive beneath it.
|
|
await expect(page.getByTestId("community-rail")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: `${SHOTS}/${theme}-collapsed.png` });
|
|
});
|
|
}
|