fix(desktop-chrome): restore collapsed content gutter

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-17 21:15:32 -07:00
committed by Carl
parent e36f660861
commit 1fda07f2fa
6 changed files with 159 additions and 83 deletions
+1
View File
@@ -894,6 +894,7 @@ export function AppShell() {
/>
) : null}
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
+16 -1
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}
+8 -7
View File
@@ -36,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;
@@ -217,8 +218,8 @@ const SidebarProvider = React.forwardRef<
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// Expose semantic state so Tailwind descendants can style both modes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
@@ -360,8 +361,8 @@ const Sidebar = React.forwardRef<
/>
<div
className={cn(
"absolute inset-y-0 z-10 hidden h-full w-(--sidebar-width) overflow-hidden 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",
"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]: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)]",
@@ -378,7 +379,7 @@ const Sidebar = React.forwardRef<
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"
>
<div
className="flex h-full w-full origin-top flex-col transition-[opacity,scale,translate] duration-200 ease-linear group-data-[collapsible=offcanvas]:translate-x-6 group-data-[collapsible=offcanvas]:scale-95 group-data-[collapsible=offcanvas]:opacity-0"
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}
@@ -708,7 +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",
"after:absolute after:-inset-2 after:md:hidden",
MOBILE_ACTION_HIT_AREA,
"group-data-[collapsible=icon]:hidden",
className,
)}
@@ -853,7 +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",
"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",
+48 -3
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) {
@@ -25,6 +26,7 @@ const COMMUNITY_B = {
async function expectContentSurfaceHorizontalGutters(
page: import("@playwright/test").Page,
expectedLeftGutter = 1,
) {
const [mainInsetBox, contentBox] = await Promise.all([
page.locator("[data-buzz-glass-inset]").boundingBox(),
@@ -37,7 +39,7 @@ async function expectContentSurfaceHorizontalGutters(
(mainInsetBox?.x ?? 0) +
(mainInsetBox?.width ?? 0) -
((contentBox?.x ?? 0) + (contentBox?.width ?? 0));
expect(Math.abs(leftGutter - 1)).toBeLessThan(0.5);
expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5);
expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5);
}
@@ -82,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}`);
@@ -1152,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("/");
@@ -1168,7 +1199,18 @@ test.describe("community rail", () => {
await expect(
page.locator('[data-side="left"][data-state="collapsed"]'),
).toBeVisible();
await expectContentSurfaceHorizontalGutters(page);
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 ({
@@ -1201,6 +1243,9 @@ 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);
});
@@ -63,87 +63,82 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) {
const railBoxBeforeCollapse = await communityRail.boundingBox();
expect(railBoxBeforeCollapse).not.toBeNull();
await page.locator('[data-sidebar="trigger"]').first().click();
// Sample every rendered frame while the sidebar crosses the rail. The
// community list must remain the painted hit target without moving or
// fading at any point in the transition, while the sidebar content visibly
// recedes in place before the shell finishes closing.
const transitionFrames = await communityButton.evaluate(async (button) => {
// 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) || !sidebarContent) return [];
const frames: Array<{
elapsed: number;
hitRail: boolean;
opacity: string;
sidebarOpacity: number;
sidebarScale: string;
sidebarTranslateX: number;
visibility: string;
x: number;
y: number;
}> = [];
const startedAt = performance.now();
while (performance.now() - startedAt < 250) {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const buttonBox = button.getBoundingClientRect();
const railBox = rail.getBoundingClientRect();
const hit = document.elementFromPoint(
buttonBox.x + buttonBox.width / 2,
buttonBox.y + buttonBox.height / 2,
);
const style = getComputedStyle(rail);
const sidebarStyle = getComputedStyle(sidebarContent);
frames.push({
elapsed: performance.now() - startedAt,
hitRail: hit === rail || rail.contains(hit),
opacity: style.opacity,
sidebarOpacity: Number.parseFloat(sidebarStyle.opacity),
sidebarScale: sidebarStyle.scale,
sidebarTranslateX: Number.parseFloat(sidebarStyle.translate),
visibility: style.visibility,
x: railBox.x,
y: railBox.y,
});
if (!(rail instanceof HTMLElement) || !trigger || !sidebarContent) {
return null;
}
return frames;
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(transitionFrames.length).toBeGreaterThan(1);
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);
expect(
transitionFrames.every(
(frame) =>
frame.hitRail &&
frame.opacity === "1" &&
frame.visibility === "visible" &&
frame.x === railBoxBeforeCollapse?.x &&
frame.y === railBoxBeforeCollapse?.y,
),
).toBe(true);
const midTransitionFrames = transitionFrames.filter(
(frame) =>
frame.sidebarOpacity > 0 &&
frame.sidebarOpacity < 1 &&
frame.sidebarScale !== "none" &&
frame.sidebarScale !== "0.95" &&
frame.sidebarTranslateX > 0 &&
frame.sidebarTranslateX < 24,
);
expect(midTransitionFrames.length).toBeGreaterThan(1);
expect(
transitionFrames.some(
(frame) => frame.elapsed >= 100 && frame.sidebarOpacity > 0.05,
),
).toBe(true);
// Let the 200ms slide finish; visibility flips at the transition's end.
await page.waitForTimeout(250);
+20 -1
View File
@@ -545,7 +545,7 @@ test("scales the sidebar backward while its chrome closes", async ({
await expect(sidebarSurface).toHaveCSS("opacity", "0");
await expect(sidebar).toHaveCSS("pointer-events", "none");
await expect(sidebar).toHaveCSS("overflow", "hidden");
await expect(sidebar).toHaveCSS("overflow", "visible");
await expect(sidebar.locator(':scope > [data-sidebar="sidebar"]')).toHaveCSS(
"background-color",
await sidebarSurface.evaluate((element) => {
@@ -581,6 +581,25 @@ test("scales the sidebar backward while its chrome closes", async ({
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" });