diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index ddd454044..ecb412422 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -74,13 +74,16 @@ bundled/bestie/ builtin Bestie page
bundled/inbox/ builtin Inbox page for unread conversations and mentions
```
-The host composes the channel sidebar beside independently mounted pages, with
-Settings and Me supplying their own sidebar contents. Me currently has an empty
-sidebar sharing the channel sidebar's frame and saved width. The channel sidebar reuses
+The host composes the Channels sidebar beside independently mounted pages. Settings
+has its own host-owned navigation; a selected page can replace the Channels sidebar
+through its optional `sidebar` contribution. Me uses this contract and retains its
+existing sidebar frame and saved width. The channel sidebar reuses
session-owned roster, unread, creation and preferences capabilities; it does not
retain a hidden Channels page or message reader. Sidebar and page render errors
-have separate boundaries. Sidebar presentation helpers currently remain importable
-from `bundled/channels`; no public sidebar contribution contract is introduced.
+have separate boundaries. A page sidebar is isolated by the same owned-contribution
+boundary, so a failure falls back to a concise alert without taking down the page,
+Settings, or shell. The host owns the slot, collapse state, labels, and narrow drawer;
+the page owns its sidebar content and local interaction state.
Channels is the page-authoring example, not a thin registration wrapper over a
host-owned product page. Keep page-specific components, styles, interactions and tests
@@ -97,7 +100,7 @@ source imports are not a versioned external SDK. See
## Starting contracts
A plugin exports `inject` and `apply(ctx)`. Pages register with
-`ctx.pages.register({ id, title, layout?, companion?, primary?, placement?, icon?, component })`. Panels register with
+`ctx.pages.register({ id, title, layout?, companion?, primary?, placement?, icon?, component, sidebar? })`. Panels register with
`ctx.panels.register({ id, title, matches, order?, launcher?, component })`. IDs are local to the
plugin; the registry adds installation identity and revision and removes the
contribution when its Cordis scope ends. `primary: true` opts a page into shell
@@ -120,6 +123,48 @@ ctx.pages.register({
});
```
+A page may replace the Channels sidebar while selected. Its component receives
+the current read-only `OpenTarget` and, when present, shell-generated primary
+Pages navigation as `children`. It supplies only its sidebar contents within the
+existing sidebar slot. Wrap that content in the existing `shell-sidebar-default`
+layout role to receive the default sidebar width and shell sizing constraints
+(260px default width, 220px minimum, with the shell's available-width clamp). That
+role provides outer slot geometry and its divider; it does not provide an inner
+surface, frame, padding, or content layout. The contributor owns those details.
+External authors use this CSS role and their own markup; internal source components
+such as `SidebarFrame` are not part of the author SDK. The host owns the slot and
+collapse/drawer interaction. A page may manage its own width preference; Me retains
+its existing Me-specific behavior. Pages do not receive navigation completion
+authority or own routing. On wide layouts the shell labels its toggle with the page title;
+on narrow layouts the existing Show/Hide navigation drawer, focus handoff, Escape,
+inert, and ARIA behavior remain shell-owned. Pages without `sidebar` continue to
+use Channels. The host isolates sidebar rendering with `OwnedContribution`; a
+throwing sidebar shows a concise alert inside the standard fallback frame while
+the selected page and shell remain usable.
+
+```tsx
+import type { PageSidebarProps } from "@buzz/author";
+
+function ActivitySidebar({ target, children }: PageSidebarProps) {
+ return (
+
+
+
+ );
+}
+
+ctx.pages.register({
+ id: "activity",
+ title: "Activity",
+ component: ActivityPage,
+ sidebar: ActivitySidebar,
+});
+```
+
Header entries move into the labelled More pages popover when they cannot fit,
including at narrow widths. Toolbar and overflow entries use native-button
navigation and `aria-current`, not companion-panel toggles. The wide topbar uses
diff --git a/docs/shell-design.md b/docs/shell-design.md
index 782e42a46..662f33f95 100644
--- a/docs/shell-design.md
+++ b/docs/shell-design.md
@@ -45,12 +45,21 @@ semantic tokens, UI authoring rules and the local component reference.
compatibility; all pages share the supplied gradient and repeating CSS dots.
Presentation is keyed by full contribution identity, not plugin-local IDs.
- `AppShell.tsx` owns the 48px header, vertical page navigation, contributed panel
- launchers, Settings access, community rail, and page frames. Page navigation sits
- above the channel list outside Settings, using its saved sidebar width
+ launchers, Settings access, community rail, and page frames. A page may set
+ `Page.sidebar` to contribute in the shell's existing `shell-sidebar-default`
+ slot; when absent, `App.tsx` keeps the ChannelSidebar fallback. Page contributors
+ own their frame and local layout inside that slot. The host owns collapse and
+ narrow-drawer behavior. Supplied Pages navigation children are optional: Me uses
+ its own navigation instead, and page search still lists every active page. The
+ host delivers sidebar targets only after route validation and scope selection
+ are ready; rejected or waiting page targets retain neutral host page navigation.
+ A failed sidebar's alert and page destinations share a scrollport, keeping recovery
+ rows reachable at short heights and enlarged text. Me uses this same page-sidebar
+ contract. Page navigation sits above the channel list outside Settings, using its saved sidebar width
and resize behavior. Settings replaces that region with `SettingsSidebar.tsx`,
preserving the same width (220px minimum) and returning to the previous view
with Back. `App.tsx` composes `features/channel-navigation/ChannelSidebar`
- through an ordinary render prop; there is no portal or plugin contract expansion.
+ through an ordinary render prop; there is no portal.
Sidebar session state resets on scope/connection generation without remounting
unrelated pages. Its own error boundary keeps page navigation and Settings usable.
Page buttons use shared navigation rows and focus the main region on selection.
@@ -60,8 +69,9 @@ 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
+ Contributed-sidebar pages, Me, Messages, 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.
The same header keeps history and account/search actions, plus a centered
navigation strip and full-page toolbar icons. Real control widths, native insets
diff --git a/src/app/App.profile.test.tsx b/src/app/App.profile.test.tsx
index 5262fce99..d9df384a1 100644
--- a/src/app/App.profile.test.tsx
+++ b/src/app/App.profile.test.tsx
@@ -19,6 +19,7 @@ import { matchesEvent } from "../features/relay/projection";
import type { ReadFilter } from "../features/relay/events";
import { composerDOMFixture } from "../features/messages/composer-testing";
import type { ComposerInputElement } from "../features/messages/composer-dom";
+import { sidebarFixtureState } from "./sidebar-plugin.fixture";
composerDOMFixture();
@@ -40,6 +41,10 @@ vi.mock("../bundled", async () => ({
manifest: { id: "buzz.profiles", name: "Profiles", apiVersion: 1 },
module: await import("../bundled/profiles"),
},
+ {
+ manifest: { id: "test.sidebar", name: "Sidebar fixture", apiVersion: 1 },
+ module: await import("./sidebar-plugin.fixture"),
+ },
],
}));
const key = new Uint8Array(32).fill(6),
@@ -71,6 +76,8 @@ afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
localStorage.clear();
+ sidebarFixtureState.broken = true;
+ sidebarFixtureState.onTarget = undefined;
window.history.replaceState(null, "", "/");
});
@@ -165,6 +172,231 @@ it("focuses an unavailable profile on first opening and returns focus after Esca
expect(trigger).toHaveFocus();
});
+it("renders an owned page sidebar with the current target and isolates its failure", async () => {
+ const user = await setup();
+ sidebarFixtureState.broken = true;
+ await act(async () => {
+ await services?.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "main",
+ route: { version: 1, params: "first" },
+ });
+ });
+ expect(await screen.findByText("Beacon page content")).toBeVisible();
+ expect(
+ screen.getByRole("complementary", { name: "Beacon sidebar" }),
+ ).toHaveTextContent("main:first");
+ const sidebarDraft = screen.getByRole("textbox", { name: "Sidebar draft" });
+ await user.type(sidebarDraft, "keep this draft");
+ expect(
+ within(
+ screen.getByRole("complementary", { name: "Beacon sidebar" }),
+ ).getByRole("navigation", { name: "Pages" }),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "Hide Beacon sidebar" }),
+ ).toBeVisible();
+
+ await act(async () => {
+ await services?.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "main",
+ route: { version: 1, params: "second" },
+ });
+ });
+ expect(
+ await within(
+ screen.getByRole("complementary", { name: "Beacon sidebar" }),
+ ).findByText("main:second"),
+ ).toBeVisible();
+ expect(screen.getByRole("textbox", { name: "Sidebar draft" })).toHaveValue(
+ "keep this draft",
+ );
+ expect(screen.getByRole("textbox", { name: "Sidebar draft" })).toBe(
+ sidebarDraft,
+ );
+
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ await act(async () => {
+ await services?.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "main",
+ route: { version: 1, params: "broken-sidebar" },
+ });
+ });
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "This page’s sidebar is unavailable.",
+ );
+ expect(screen.getByText("Beacon page content")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Your profile" })).toBeVisible();
+ expect(screen.getByRole("navigation", { name: "Pages" })).toBeVisible();
+
+ await act(async () => {
+ await services?.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "main",
+ route: { version: 1, params: "recovered" },
+ });
+ });
+ expect(
+ await screen.findByRole("complementary", { name: "Beacon sidebar" }),
+ ).toHaveTextContent("main:recovered");
+
+ await act(async () => {
+ await services?.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "plain",
+ });
+ });
+ expect(await screen.findByText("Plain page content")).toBeVisible();
+ expect(
+ screen.getByRole("complementary", { name: "Channel sidebar" }),
+ ).toBeVisible();
+
+ await act(async () => {
+ await services?.navigation.open({ version: 1, kind: "settings" });
+ });
+ expect(
+ await screen.findByRole("region", { name: "Settings" }),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("complementary", { name: "Settings sidebar" }),
+ ).toBeVisible();
+
+ await act(async () => {
+ await services?.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "broken",
+ });
+ });
+ expect(await screen.findByText("Broken Beacon page content")).toBeVisible();
+ const fallback = screen.getByRole("complementary", {
+ name: "Broken Beacon sidebar",
+ });
+ expect(fallback).toContainElement(within(fallback).getByRole("alert"));
+ expect(
+ within(fallback).getByRole("navigation", { name: "Pages" }),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "Hide Broken Beacon sidebar" }),
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Your profile" })).toBeVisible();
+
+ await act(async () => {
+ await services?.plugins.change("disable", "test.sidebar");
+ });
+ sidebarFixtureState.broken = false;
+ await act(async () => {
+ await services?.plugins.change("enable", "test.sidebar");
+ });
+ expect(
+ await screen.findByRole("complementary", {
+ name: "Recovered Beacon sidebar",
+ }),
+ ).toBeVisible();
+ expect(screen.getByText("Broken Beacon page content")).toBeVisible();
+ } finally {
+ error.mockRestore();
+ }
+});
+
+it.each([
+ {
+ name: "rejected route parameters",
+ params: 42,
+ scope: undefined,
+ reason: "unavailable",
+ },
+ {
+ name: "a foreign viewer",
+ params: "foreign-viewer",
+ scope: { viewer: recipient.pubkey, communityOrigin: origin },
+ reason: "denied",
+ },
+ {
+ name: "an unjoined community",
+ params: "unjoined-community",
+ scope: { viewer, communityOrigin: "https://other.example" },
+ reason: "denied",
+ },
+])("withholds a page sidebar for $name", async ({ params, scope, reason }) => {
+ await setup();
+ const current = services;
+ if (!current) throw new Error("Missing services");
+ const delivered = vi.fn();
+ sidebarFixtureState.onTarget = delivered;
+ await act(async () => {
+ expect(
+ await current.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "main",
+ route: { version: 1, params },
+ ...(scope !== undefined ? { scope } : {}),
+ }),
+ ).toMatchObject({ status: "failed", reason });
+ });
+ expect(
+ await screen.findByRole("heading", {
+ name: "This destination couldn’t open",
+ }),
+ ).toBeVisible();
+ expect(screen.queryByText("Beacon page content")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("complementary", { name: "Beacon sidebar" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("navigation", { name: "Pages" })).toBeVisible();
+ expect(delivered).not.toHaveBeenCalled();
+});
+
+it("delivers a page sidebar target only after selecting its personal scope", async () => {
+ await setup();
+ const current = services;
+ if (!current) throw new Error("Missing services");
+ const observed: {
+ selected: string | null;
+ relayScope: string | undefined;
+ }[] = [];
+ sidebarFixtureState.onTarget = () => {
+ observed.push({
+ selected: current.communities.snapshot().selected,
+ relayScope: current.relay.snapshot().scope,
+ });
+ };
+ await act(async () => {
+ expect(
+ await current.navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "test.sidebar",
+ pageId: "main",
+ route: { version: 1, params: "personal-space" },
+ scope: null,
+ }),
+ ).toMatchObject({ status: "opened" });
+ });
+ expect(await screen.findByText("Beacon page content")).toBeVisible();
+ expect(observed.length).toBeGreaterThan(0);
+ for (const observation of observed) {
+ expect(observation.selected).toBeNull();
+ expect(observation.relayScope).toBeUndefined();
+ }
+});
+
it("opens the viewer's community profile from the account menu avatar", async () => {
const user = await setup();
const trigger = screen.getByRole("button", { name: "Your profile" });
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 3fe917608..feac6f167 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,6 +1,5 @@
// FOUNDATION: Startup, navigation, contributed pages, and built-in Settings.
import { IdentitySetup } from "../features/identity/IdentitySetup";
-import { MeSidebar } from "../bundled/me/MeSidebar";
import { ChannelSidebar } from "../features/channel-navigation/ChannelSidebar";
import { ChannelNavigationProvider } from "../features/channel-navigation/ChannelNavigationState";
import { ToastProvider } from "../shared/design-system/ui/Toast";
@@ -31,6 +30,8 @@ import { PanelCard } from "../features/panels/PanelCard";
import { communityDestination } from "../features/communities/destination";
import { profileTarget } from "../features/profiles/target";
import { setLaunchReady } from "./launch";
+import { OwnedContribution } from "../plugins/OwnedContribution";
+import { SidebarFrame } from "../features/channel-navigation/SidebarFrame";
export function App({ services }: { services: AppServices }) {
const identity = services.identity;
@@ -129,6 +130,12 @@ function ConnectedApp({ services }: { services: AppServices }) {
const ownProfile =
client.selected && client.viewer ? profileTarget(client.viewer) : undefined;
const pageOwnsCompanion = !!route.page?.companion;
+ // A registration lookup does not admit the target's parameters and scope.
+ const pageSidebarReady =
+ startup === "ready" &&
+ !route.waiting &&
+ !route.failure &&
+ route.state.status !== "failed";
// Keep the parser launch surface through local bootstrap, not network refresh.
if (!settings && restoring)
return document.getElementById("buzz-launch") ? null : (
@@ -177,12 +184,37 @@ function ConnectedApp({ services }: { services: AppServices }) {
});
}}
/>
- ) : route.selected === "buzz.me/me" ? (
-
+ ) : route.page?.sidebar && pageSidebarReady ? (
+
+
+