diff --git a/docs/shell-design.md b/docs/shell-design.md
index 782e42a46..6914bc328 100644
--- a/docs/shell-design.md
+++ b/docs/shell-design.md
@@ -60,9 +60,11 @@ semantic tokens, UI authoring rules and the local component reference.
220px disclosure overlays content, supports Escape, and keeps sidebar state
mounted. A navigation selection closes the phone drawer and hands focus to the
main content; this includes conversation and Settings-section selections.
- Me, Messages, Agents, and desktop Settings share an animated header toggle; hiding
- the sidebar preserves its mounted state and saved width. Reduced motion disables
- the transition. Other desktop pages retain the visible sidebar.
+ Me, Messages, Agents, desktop Settings, and any selected primary page share an
+ animated header toggle; hiding the sidebar preserves its mounted state and saved
+ width. Pages without a Channel or Me-specific label use generic "Hide sidebar" /
+ "Show sidebar" labels. Reduced motion disables the transition. Non-primary
+ desktop destinations retain the visible sidebar.
The same header keeps history and account/search actions, plus a centered
navigation strip and full-page toolbar icons. Real control widths, native insets
and text scale determine whether header pages fit without overlap. Otherwise
diff --git a/src/app/shell/AppShell.test.tsx b/src/app/shell/AppShell.test.tsx
index 2ac323069..4f5f5304c 100644
--- a/src/app/shell/AppShell.test.tsx
+++ b/src/app/shell/AppShell.test.tsx
@@ -112,6 +112,138 @@ it("hides and shows the channel sidebar without re-rendering its content", async
expect(row()).not.toHaveAttribute("aria-current");
});
+it("lets a plugin page hide and show the shell sidebar", async () => {
+ const current = createServices();
+ services = current;
+ const pages: RegisteredPage[] = [
+ {
+ id: "main",
+ key: "example.plugin/main",
+ pluginId: "example.plugin",
+ revision: "1",
+ title: "Beacon",
+ component: () => null,
+ primary: true,
+ },
+ ];
+ render(
+
+ {}}
+ tone="default"
+ communities={current.communities}
+ accountActions={current.accountActions}
+ >
+ content
+
+ ,
+ );
+
+ const navigation = document.getElementById("shell-navigation");
+ expect(
+ within(screen.getByRole("navigation", { name: "Pages" })).getByRole(
+ "button",
+ { name: "Beacon" },
+ ),
+ ).toBeInTheDocument();
+ expect(navigation).toHaveAttribute("data-sidebar-collapsible");
+ await userEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
+ expect(navigation).toHaveAttribute("aria-hidden", "true");
+ expect(navigation).toHaveAttribute("inert");
+ expect(screen.getByRole("button", { name: "Show sidebar" })).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ expect(
+ screen.queryByRole("navigation", { name: "Pages" }),
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
+ expect(navigation).toHaveAttribute("aria-hidden", "false");
+ expect(navigation).not.toHaveAttribute("inert");
+ expect(screen.getByRole("button", { name: "Hide sidebar" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(
+ within(screen.getByRole("navigation", { name: "Pages" })).getByRole(
+ "button",
+ { name: "Beacon" },
+ ),
+ ).toBeInTheDocument();
+});
+
+it("does not make a non-primary plugin page's sidebar collapsible", () => {
+ const current = createServices();
+ services = current;
+ const pages: RegisteredPage[] = [
+ {
+ id: "secondary",
+ key: "example.plugin/secondary",
+ pluginId: "example.plugin",
+ revision: "1",
+ title: "Secondary",
+ component: () => null,
+ primary: false,
+ },
+ ];
+ render(
+
+ {}}
+ tone="default"
+ communities={current.communities}
+ accountActions={current.accountActions}
+ >
+ content
+
+ ,
+ );
+
+ expect(document.getElementById("shell-navigation")).not.toHaveAttribute(
+ "data-sidebar-collapsible",
+ );
+ expect(
+ screen.queryByRole("button", { name: "Hide sidebar" }),
+ ).not.toBeInTheDocument();
+});
+
+it("keeps built-in sidebar controls available before page contributions load", () => {
+ const current = createServices();
+ services = current;
+ const pages: RegisteredPage[] = [];
+ const shell = (selected: string) => (
+
+ {}}
+ tone="default"
+ communities={current.communities}
+ accountActions={current.accountActions}
+ >
+ content
+
+
+ );
+ const { rerender } = render(shell("buzz.channels/channels"));
+ expect(
+ screen.getByRole("button", { name: "Hide Channel sidebar" }),
+ ).toHaveAttribute("aria-expanded", "true");
+
+ rerender(shell("buzz.me/me"));
+ expect(
+ screen.getByRole("button", { name: "Hide Me sidebar" }),
+ ).toHaveAttribute("aria-expanded", "true");
+});
+
it("shows a primary plugin page's declared icon beside its nav label", () => {
const current = createServices();
services = current;
diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx
index d7112a4ed..6007fdb0f 100644
--- a/src/app/shell/AppShell.tsx
+++ b/src/app/shell/AppShell.tsx
@@ -96,7 +96,15 @@ export function AppShell({
}, []);
const meNavigation = selected === "buzz.me/me";
const collapsibleSidebar =
- channelsNavigation || meNavigation || selected === "settings";
+ channelsNavigation ||
+ meNavigation ||
+ selected === "settings" ||
+ pages.some((page) => page.key === selected && page.primary);
+ const sidebarLabel = meNavigation
+ ? "Me sidebar"
+ : channelsNavigation
+ ? "Channel sidebar"
+ : "sidebar";
const [sidebarOpen, setSidebarOpen] = useState(true);
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationToggle = useRef(null);
@@ -105,9 +113,7 @@ export function AppShell({
? navigationOpen
? "Hide navigation"
: "Show navigation"
- : sidebarOpen
- ? `Hide ${meNavigation ? "Me" : "Channel"} sidebar`
- : `Show ${meNavigation ? "Me" : "Channel"} sidebar`;
+ : `${sidebarOpen ? "Hide" : "Show"} ${sidebarLabel}`;
// biome-ignore lint/correctness/useExhaustiveDependencies: Only a navigation attempt closes the drawer.
useEffect(() => {
if (navigationOpen && navigationToggle.current?.getClientRects().length) {
diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css
index bcf88546f..9555db587 100644
--- a/src/shared/styles/globals.css
+++ b/src/shared/styles/globals.css
@@ -367,6 +367,9 @@
.shell-navigation {
display: none;
}
+ .shell-navigation[data-sidebar-collapsible] {
+ display: none;
+ }
.shell-navigation[data-expanded="true"] {
@apply absolute inset-y-0 left-0 z-20 flex;
background: var(--surface-panel);
diff --git a/tests/browser/navigation.mjs b/tests/browser/navigation.mjs
index f0d17b0e7..4e5413a1e 100644
--- a/tests/browser/navigation.mjs
+++ b/tests/browser/navigation.mjs
@@ -65,7 +65,7 @@ export async function settleShellToggle(page) {
).toHaveAccessibleName(
page.viewportSize().width <= 650
? /^(Show|Hide) navigation$/
- : /^(Show|Hide) Channel sidebar$/,
+ : /^(Show|Hide) (?:Channel |Me )?sidebar$/,
);
}
diff --git a/tests/browser/navigation.spec.mjs b/tests/browser/navigation.spec.mjs
index 8797bef78..cb3ca2a07 100644
--- a/tests/browser/navigation.spec.mjs
+++ b/tests/browser/navigation.spec.mjs
@@ -9,6 +9,28 @@ const composer = (page, name) =>
const entry = (page) =>
page.evaluate(() => history.state.buzzNavigationV1.entry);
+test("a collapsible sidebar yields to narrow drawer styles during resize", async ({
+ page,
+ app,
+}) => {
+ await open(page, app);
+ const navigation = page.locator("#shell-navigation");
+ await expect(navigation).toHaveAttribute("data-sidebar-collapsible", "true");
+
+ await page.setViewportSize({ width: 390, height: 900 });
+ // Model the desktop marker remaining for a frame until React observes the
+ // media-query change; narrow drawer CSS must still win during that interval.
+ await navigation.evaluate((element) => {
+ element.setAttribute("data-sidebar-collapsible", "true");
+ });
+ await expect(navigation).toHaveCSS("display", "none");
+
+ await button(page, "Show navigation").click();
+ await expect(navigation).toHaveAttribute("data-expanded", "true");
+ await expect(navigation).toHaveCSS("display", "flex");
+ await expect(navigation).toBeVisible();
+});
+
test("a retired contributed Settings destination fails promptly on Back", async ({
page,
app,
diff --git a/tests/browser/sidenav-polish.spec.mjs b/tests/browser/sidenav-polish.spec.mjs
index 4880973af..495360b0b 100644
--- a/tests/browser/sidenav-polish.spec.mjs
+++ b/tests/browser/sidenav-polish.spec.mjs
@@ -391,7 +391,7 @@ test("Settings retains the sidebar toggle across desktop and narrow layouts", as
});
const toggle = page.locator("[data-shell-sidebar-toggle]");
await expect(toggle).toHaveCount(1);
- await expect(toggle).toHaveAccessibleName("Show Channel sidebar");
+ await expect(toggle).toHaveAccessibleName("Show sidebar");
await expect(sidebar).not.toBeVisible();
await toggle.click();
await expect(sidebar).toBeVisible();
@@ -411,7 +411,7 @@ test("Settings retains the sidebar toggle across desktop and narrow layouts", as
await expect(sidebar).not.toBeVisible();
await expect(toggle).toBeFocused();
await page.setViewportSize({ width: 1440, height: 950 });
- await expect(toggle).toHaveAccessibleName("Show Channel sidebar");
+ await expect(toggle).toHaveAccessibleName("Show sidebar");
await toggle.click();
await expect(sidebar).toBeVisible();
});