Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 (
<div className="shell-sidebar-default">
<aside className="activity-sidebar" aria-label="Activity sidebar">
<header>Activity</header>
<p>Current destination: {target.kind}</p>
{children}
</aside>
</div>
);
}

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
Expand Down
20 changes: 15 additions & 5 deletions docs/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
232 changes: 232 additions & 0 deletions src/app/App.profile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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),
Expand Down Expand Up @@ -71,6 +76,8 @@ afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
localStorage.clear();
sidebarFixtureState.broken = true;
sidebarFixtureState.onTarget = undefined;
window.history.replaceState(null, "", "/");
});

Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading