From c0282939c3f6475478735fcb0051d506be756c0f Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Sun, 11 Oct 2026 00:28:30 +0000 Subject: [PATCH] Add page-owned sidebar contribution Co-authored-by: Codex (gpt-6.1-sol) Co-authored-by: Codex (gpt-6-luna) Signed-off-by: Kyle Zemek --- docs/plugin-architecture.md | 57 +++- docs/shell-design.md | 20 +- src/app/App.profile.test.tsx | 232 ++++++++++++++++ src/app/App.tsx | 46 +++- src/app/pages.integration.test.mjs | 19 ++ src/app/shell/AppShell.test.tsx | 125 ++++++++- src/app/shell/AppShell.tsx | 13 +- src/app/sidebar-plugin.fixture.tsx | 63 +++++ src/bundled/me/index.tsx | 4 + .../conversation/ContributionBoundary.tsx | 14 +- src/features/pages/service.test.ts | 34 +++ src/features/pages/service.ts | 10 + src/plugins/OwnedContribution.tsx | 8 +- src/plugins/author.test.mjs | 14 +- src/plugins/author.ts | 2 +- tests/browser/page-placement.spec.mjs | 255 ++++++++++++++++++ tests/browser/plugin-fixtures.tsx | 34 +++ 17 files changed, 921 insertions(+), 29 deletions(-) create mode 100644 src/app/sidebar-plugin.fixture.tsx 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 ? ( + + +
+

This page’s sidebar is unavailable.

+ {pageNavigation} +
+
+ + } + > + {(entry) => { + const Sidebar = entry.sidebar; + return Sidebar ? ( + {pageNavigation} + ) : null; + }} +
+ ) : route.page?.sidebar ? ( +
+ +
+ {pageNavigation} +
+
+
) : ( page.key === "buzz.me/me"); + const renderMeSidebar = (page) => + renderToStaticMarkup( + createElement(page.sidebar, { + target: { version: 1, kind: "home" }, + }), + ); assert.equal(me.primary, true); assert.equal(me.placement, "topbar"); + assert.match(renderMeSidebar(me), /aria-label="Me sidebar"/); + assert.match( + renderMeSidebar(me), + /Connect to a community to see your conversations\./, + ); assert.match( renderToStaticMarkup(createElement(me.component)), /Connect to a community to work with your agents\./, @@ -97,6 +108,14 @@ test("the app runtime exposes ready bundled pages and removes them on disable", services.pages.snapshot().some((page) => page.key === "buzz.me/me"), ), ); + const reenabledMe = services.pages + .snapshot() + .find((page) => page.key === "buzz.me/me"); + assert.match(renderMeSidebar(reenabledMe), /aria-label="Me sidebar"/); + assert.match( + renderMeSidebar(reenabledMe), + /Connect to a community to see your conversations\./, + ); await services.plugins.change("disable", "buzz.me"); const inbox = services.pages .snapshot() diff --git a/src/app/shell/AppShell.test.tsx b/src/app/shell/AppShell.test.tsx index 2ac323069..06214425b 100644 --- a/src/app/shell/AppShell.test.tsx +++ b/src/app/shell/AppShell.test.tsx @@ -8,7 +8,7 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { RegisteredPage } from "../../features/pages/service"; import { createServices, type AppServices } from "../services"; @@ -112,6 +112,129 @@ it("hides and shows the channel sidebar without re-rendering its content", async expect(row()).not.toHaveAttribute("aria-current"); }); +it("uses a contributed page title for the wide toggle and retains sidebar state", async () => { + const current = createServices(); + services = current; + function ContributedSidebar() { + const [value, setValue] = useState("kept"); + return ( + + ); + } + const sidebar = () => ; + const pages: RegisteredPage[] = [ + { + id: "main", + key: "example.plugin/main", + pluginId: "example.plugin", + revision: "1", + title: "Beacon", + component: () => null, + sidebar: () => null, + }, + ]; + render( + + {}} + tone="default" + sidebar={sidebar} + communities={current.communities} + accountActions={current.accountActions} + > + content + + , + ); + const nav = document.getElementById("shell-navigation"); + const contribution = screen.getByRole("complementary", { + name: "Beacon sidebar", + }); + await userEvent.click( + within(contribution).getByRole("button", { name: "kept" }), + ); + await userEvent.click( + screen.getByRole("button", { name: "Hide Beacon sidebar" }), + ); + expect(nav).toHaveAttribute("aria-hidden", "true"); + expect(nav).toHaveAttribute("inert"); + await userEvent.click( + screen.getByRole("button", { name: "Show Beacon sidebar" }), + ); + expect(nav).toHaveAttribute("aria-hidden", "false"); + expect( + within(contribution).getByRole("button", { name: "changed" }), + ).toBeVisible(); +}); + +it("keeps narrow contributed sidebars in the existing Escape-dismissed navigation drawer", async () => { + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: true, + addEventListener() {}, + removeEventListener() {}, + })); + const current = createServices(); + services = current; + const sidebar = () => ( + + ); + const pages: RegisteredPage[] = [ + { + id: "main", + key: "example.plugin/main", + pluginId: "example.plugin", + revision: "1", + title: "Beacon", + component: () => null, + sidebar: () => null, + }, + ]; + const shell = (attempt: string) => ( + + {}} + tone="default" + sidebar={sidebar} + communities={current.communities} + accountActions={current.accountActions} + > + content + + + ); + const { rerender } = render(shell("first")); + const nav = document.getElementById("shell-navigation"); + const toggle = screen.getByRole("button", { name: "Show navigation" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + expect(nav).toHaveAttribute("aria-hidden", "false"); + const action = screen.getByRole("button", { name: "Sidebar action" }); + action.focus(); + await userEvent.keyboard("{Escape}"); + expect(nav).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByRole("button", { name: "Show navigation" })).toHaveFocus(); + + await userEvent.click( + screen.getByRole("button", { name: "Show navigation" }), + ); + rerender(shell("second")); + expect(nav).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByRole("button", { name: "Show navigation" })).toBeVisible(); +}); + 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..3866916da 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -82,6 +82,10 @@ export function AppShell({ children: ReactNode; }) { const fillsWorkspace = workspace || selected === "settings"; + const selectedPage = pages.find((page) => page.key === selected); + const selectedSidebarTitle = selectedPage?.sidebar + ? selectedPage.title + : undefined; const channelsNavigation = selected === "buzz.channels/channels" || selected === "buzz.agents/agents"; const [narrow, setNarrow] = useState( @@ -94,9 +98,10 @@ export function AppShell({ query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); - const meNavigation = selected === "buzz.me/me"; const collapsibleSidebar = - channelsNavigation || meNavigation || selected === "settings"; + channelsNavigation || + selected === "settings" || + selectedSidebarTitle !== undefined; const [sidebarOpen, setSidebarOpen] = useState(true); const [navigationOpen, setNavigationOpen] = useState(false); const navigationToggle = useRef(null); @@ -106,8 +111,8 @@ export function AppShell({ ? "Hide navigation" : "Show navigation" : sidebarOpen - ? `Hide ${meNavigation ? "Me" : "Channel"} sidebar` - : `Show ${meNavigation ? "Me" : "Channel"} sidebar`; + ? `Hide ${selectedSidebarTitle ?? "Channel"} sidebar` + : `Show ${selectedSidebarTitle ?? "Channel"} sidebar`; // biome-ignore lint/correctness/useExhaustiveDependencies: Only a navigation attempt closes the drawer. useEffect(() => { if (navigationOpen && navigationToggle.current?.getClientRects().length) { diff --git a/src/app/sidebar-plugin.fixture.tsx b/src/app/sidebar-plugin.fixture.tsx new file mode 100644 index 000000000..76d29cc41 --- /dev/null +++ b/src/app/sidebar-plugin.fixture.tsx @@ -0,0 +1,63 @@ +import type { PluginModule } from "../plugins/api"; +import { useEffect, useState } from "react"; +import type { OpenTarget } from "../features/navigation/targets"; + +export const sidebarFixtureState: { + broken: boolean; + onTarget?: ((target: OpenTarget) => void) | undefined; +} = { broken: true }; + +function BeaconSidebar({ + target, + children, +}: import("../features/pages/service").PageSidebarProps) { + const [draft, setDraft] = useState(""); + useEffect(() => sidebarFixtureState.onTarget?.(target), [target]); + const route = target.kind === "page" ? target.route?.params : undefined; + if (route === "broken-sidebar") throw new Error("route sidebar failure"); + return ( + + ); +} + +export const inject = ["pages"]; +export const apply: PluginModule["apply"] = (ctx) => { + ctx.pages.register({ + id: "main", + title: "Beacon", + primary: true, + component: () =>

Beacon page content

, + route: { version: 1, validate: (params) => typeof params === "string" }, + sidebar: BeaconSidebar, + }); + ctx.pages.register({ + id: "broken", + title: "Broken Beacon", + component: () =>

Broken Beacon page content

, + sidebar: () => + !sidebarFixtureState.broken ? ( + + ) : ( + (() => { + throw new Error("sidebar fixture failure"); + })() + ), + }); + ctx.pages.register({ + id: "plain", + title: "Plain fixture", + component: () =>

Plain page content

, + }); +}; diff --git a/src/bundled/me/index.tsx b/src/bundled/me/index.tsx index 3ceddbe13..d0daebb3c 100644 --- a/src/bundled/me/index.tsx +++ b/src/bundled/me/index.tsx @@ -1,6 +1,7 @@ import type { PluginModule } from "../../plugins/api"; import { SessionsPage } from "../sessions/SessionsPage"; import { isMeRoute } from "./routes"; +import { MeSidebar } from "./MeSidebar"; export const inject = [ "pages", @@ -20,6 +21,9 @@ export const apply: PluginModule["apply"] = (ctx) => { placement: "topbar", layout: "workspace", companion: true, + sidebar: ({ target }) => ( + + ), route: { version: 1, validate: isMeRoute }, component: ({ navigation, companion }) => (
diff --git a/src/features/conversation/ContributionBoundary.tsx b/src/features/conversation/ContributionBoundary.tsx index f1f0d0802..b690ae1ca 100644 --- a/src/features/conversation/ContributionBoundary.tsx +++ b/src/features/conversation/ContributionBoundary.tsx @@ -12,10 +12,18 @@ export function contributionKey(entry: object) { return key; } export class ContributionBoundary extends Component< - { children: ReactNode; fallback: ReactNode }, - { failed: boolean } + { children: ReactNode; fallback: ReactNode; resetIdentity?: unknown }, + { failed: boolean; resetIdentity?: unknown } > { - state = { failed: false }; + state = { failed: false, resetIdentity: this.props.resetIdentity }; + static getDerivedStateFromProps( + props: ContributionBoundary["props"], + state: ContributionBoundary["state"], + ) { + if (props.resetIdentity !== state.resetIdentity) + return { failed: false, resetIdentity: props.resetIdentity }; + return null; + } static getDerivedStateFromError() { return { failed: true }; } diff --git a/src/features/pages/service.test.ts b/src/features/pages/service.test.ts index a32ce7bb8..4d96e7171 100644 --- a/src/features/pages/service.test.ts +++ b/src/features/pages/service.test.ts @@ -150,4 +150,38 @@ describe("page decoration validation", () => { expect(Object.keys(snapshot[0] ?? {})).not.toContain("icon"); expect(warn).not.toHaveBeenCalled(); }); + + it("requires a callable page sidebar and disposes its scoped contribution", async () => { + const root = new Context(); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const pages = new PagesService(root); + const scope = root.extend({ + pluginOwner: { id: "example", revision: "one" }, + }); + const sidebar = () => null; + const fiber = scope.plugin((ctx) => { + expect(() => + ctx.pages.register({ + id: "invalid", + title: "Invalid", + component: () => null, + sidebar: "not a component", + } as never), + ).toThrow("A page needs an id, a title, and a React component function"); + ctx.pages.register({ + id: "main", + title: "Main", + component: () => null, + sidebar, + }); + }); + await fiber.await(); + expect(typeof pages.snapshot()[0]?.sidebar).toBe("function"); + await fiber.dispose(); + expect(pages.snapshot()).toEqual([]); + await root.fiber.dispose(); + }); }); diff --git a/src/features/pages/service.ts b/src/features/pages/service.ts index 40cb350ff..e3310014a 100644 --- a/src/features/pages/service.ts +++ b/src/features/pages/service.ts @@ -13,10 +13,19 @@ export type PageProps = { navigation?: import("../navigation/service").PageNavigation | undefined; }; +/** Props for a page-owned replacement in the shell's sidebar slot. */ +export type PageSidebarProps = { + target: import("../navigation/targets").OpenTarget; + /** Shell-generated primary Pages navigation, when available. */ + children?: ReactNode | undefined; +}; + export type Page = Readonly<{ id: string; title: string; component: ComponentType; + /** Replace the Channels sidebar while this page is selected. */ + sidebar?: ComponentType; layout?: "document" | "workspace"; // Opt in only when every page state places the supplied companion card. companion?: boolean; @@ -78,6 +87,7 @@ export class PagesService extends Service implements Pages { typeof page.title !== "string" || !page.title.trim() || typeof page.component !== "function" || + (page.sidebar !== undefined && typeof page.sidebar !== "function") || (page.layout !== undefined && page.layout !== "document" && page.layout !== "workspace") || diff --git a/src/plugins/OwnedContribution.tsx b/src/plugins/OwnedContribution.tsx index 0de778601..314742ff5 100644 --- a/src/plugins/OwnedContribution.tsx +++ b/src/plugins/OwnedContribution.tsx @@ -14,14 +14,20 @@ export function OwnedContribution({ remain available.

), + resetIdentity, }: { entry: T; registry: { snapshot(): readonly T[] }; children(entry: T, active: () => boolean): ReactNode; fallback?: ReactNode; + resetIdentity?: unknown; }) { return ( - + {children} diff --git a/src/plugins/author.test.mjs b/src/plugins/author.test.mjs index 61fc9319f..ce55d9ecc 100644 --- a/src/plugins/author.test.mjs +++ b/src/plugins/author.test.mjs @@ -32,7 +32,7 @@ test("generated author package in a path with spaces exposes agentControl, host, await writeFile( join(dir, "consumer.ts"), ` -import type { Context, AgentControl, Host, PluginManifest, NamingPolicy } from "@buzz/author"; +import type { Context, AgentControl, Host, PluginManifest, NamingPolicy, PageSidebarProps } from "@buzz/author"; export const manifest: PluginManifest = { id: "example.plugin", name: "Example", apiVersion: 1, host: { @@ -40,6 +40,10 @@ export const manifest: PluginManifest = { networkOrigins: ["https://api.example.com"], }, }; +const ExampleSidebar = ({ target, children }: PageSidebarProps) => { + void target.kind; + return children; +}; export const inject = ["agentControl", "host", "identityNames", "pages"]; export function apply(ctx: Context) { const policy: NamingPolicy = { @@ -65,6 +69,14 @@ export function apply(ctx: Context) { icon: "data:image/png;base64,iVBOR", primary: true, placement: "toolbar", + sidebar: ExampleSidebar, + }); + ctx.pages.register({ + id: "bad-sidebar", + title: "Bad sidebar", + component: () => null, + // @ts-expect-error A page sidebar must be a React component. + sidebar: 1, }); ctx.pages.register({ id: "other", diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 54d26e18b..7fa3ca2cb 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -17,7 +17,7 @@ export type { } from "../features/identity-names/service"; export type { NamingIdentity } from "../features/identity-names/policy"; export type { Browser, BrowserViewProps } from "../features/browser/api"; -export type { Page, Pages } from "../features/pages/service"; +export type { Page, PageSidebarProps, Pages } from "../features/pages/service"; export type { Panel, Panels, diff --git a/tests/browser/page-placement.spec.mjs b/tests/browser/page-placement.spec.mjs index 46bc07105..ba1d81dd7 100644 --- a/tests/browser/page-placement.spec.mjs +++ b/tests/browser/page-placement.spec.mjs @@ -177,6 +177,261 @@ test("Me replaces the sidebar while Messages preserves its draft and history", a await expect(topbar.locator('[aria-selected="true"]')).toHaveCount(0); }); +test("a page sidebar uses the shell slot, title toggle, theme, and narrow drawer", async ({ + page, + app, +}) => { + await page.addInitScript(() => { + localStorage.setItem("buzz-appearance.v1", "system"); + localStorage.setItem( + "buzzodz.plugins.v1", + JSON.stringify({ + version: 2, + enabled: { "fixture.page-placement": true }, + }), + ); + }); + await page.goto(app.origin); + await openPage(page, "Messages"); + const pages = page.getByRole("navigation", { name: "Pages" }); + const destination = pages.getByRole("button", { + name: "Sidebar playground", + exact: true, + }); + await expect(destination).toBeVisible(); + await destination.focus(); + await destination.press("Enter"); + + const sidebar = page.getByRole("complementary", { name: "Fixture sidebar" }); + await expect(sidebar).toContainText("Target: sidebar"); + await expect( + page.getByRole("complementary", { name: "Channel sidebar" }), + ).toHaveCount(0); + const wideSize = await sidebar.evaluate( + (element) => element.getBoundingClientRect().width, + ); + expect(wideSize).toBeGreaterThan(220); + + await page.evaluate(() => { + document.documentElement.style.fontSize = "200%"; + }); + const shellNavigation = page.locator("#shell-navigation"); + const action = sidebar.getByRole("button", { + name: "Fixture sidebar action", + }); + const sidebarColors = []; + for (const mode of ["light", "dark"]) { + await page.emulateMedia({ colorScheme: mode }); + await expect(page.locator("html")).toHaveAttribute("data-color-mode", mode); + const geometryAndColors = await sidebar.evaluate((element) => { + const slot = element.parentElement.getBoundingClientRect(); + const action = element.querySelector("button").getBoundingClientRect(); + const style = getComputedStyle(element); + return { + slot: { + left: slot.left, + right: slot.right, + top: slot.top, + bottom: slot.bottom, + }, + action: { + left: action.left, + right: action.right, + top: action.top, + bottom: action.bottom, + }, + background: style.backgroundColor, + color: style.color, + }; + }); + sidebarColors.push({ + background: geometryAndColors.background, + color: geometryAndColors.color, + }); + expect(geometryAndColors.action.left).toBeGreaterThanOrEqual( + geometryAndColors.slot.left, + ); + expect(geometryAndColors.action.right).toBeLessThanOrEqual( + geometryAndColors.slot.right, + ); + expect(geometryAndColors.action.top).toBeGreaterThanOrEqual( + geometryAndColors.slot.top, + ); + expect(geometryAndColors.action.bottom).toBeLessThanOrEqual( + geometryAndColors.slot.bottom, + ); + await destination.focus(); + const tabStopCount = await page + .locator( + "button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])", + ) + .count(); + for (let index = 0; index <= tabStopCount; index += 1) { + if ( + await action.evaluate((element) => element === document.activeElement) + ) + break; + await page.keyboard.press("Tab"); + } + await expect(action).toBeFocused(); + await page + .getByRole("button", { name: "Hide Sidebar playground sidebar" }) + .click(); + await expect(shellNavigation).toHaveAttribute("aria-hidden", "true"); + await expect(shellNavigation).toHaveAttribute("inert"); + await expect + .poll(() => + shellNavigation.evaluate( + (element) => element.getBoundingClientRect().width, + ), + ) + .toBe(0); + await page + .getByRole("button", { name: "Show Sidebar playground sidebar" }) + .click(); + await expect(shellNavigation).toHaveAttribute("aria-hidden", "false"); + await expect + .poll(() => + shellNavigation.evaluate( + (element) => element.getBoundingClientRect().width, + ), + ) + .toBeGreaterThan(220); + } + expect(sidebarColors[0].background).not.toBe(sidebarColors[1].background); + expect(sidebarColors[0].color).not.toBe(sidebarColors[1].color); + + await page.setViewportSize({ width: 390, height: 844 }); + const showNavigation = page.getByRole("button", { name: "Show navigation" }); + await expect(showNavigation).toBeVisible(); + await showNavigation.click(); + await expect(sidebar).toBeVisible(); + const narrowGeometry = await sidebar.evaluate((element) => { + const slot = element.parentElement.getBoundingClientRect(); + const action = element.querySelector("button").getBoundingClientRect(); + return { + viewportWidth: document.documentElement.clientWidth, + slot: { left: slot.left, right: slot.right, width: slot.width }, + action: { left: action.left, right: action.right }, + }; + }); + expect(narrowGeometry.slot.width).toBeGreaterThan(0); + expect(narrowGeometry.slot.width).toBeLessThanOrEqual( + narrowGeometry.viewportWidth, + ); + expect(narrowGeometry.slot.left).toBeGreaterThanOrEqual(0); + expect(narrowGeometry.slot.right).toBeLessThanOrEqual( + narrowGeometry.viewportWidth, + ); + expect(narrowGeometry.action.left).toBeGreaterThanOrEqual( + narrowGeometry.slot.left, + ); + expect(narrowGeometry.action.right).toBeLessThanOrEqual( + narrowGeometry.slot.right, + ); + await action.focus(); + await page.keyboard.press("Escape"); + await expect(shellNavigation).toHaveAttribute("aria-hidden", "true"); + await expect(showNavigation).toBeFocused(); + await page.keyboard.press("Tab"); + expect( + await shellNavigation.evaluate((element) => + element.contains(document.activeElement), + ), + ).toBe(false); +}); + +test.describe("failed page sidebar recovery", () => { + test.use({ expectedPageFailure: true, historyCounts: { alpha: 1, beta: 0 } }); + + // A DOM emulator cannot prove that the bounded shell fallback admits real scrolling. + test("recovery navigation scrolls at short heights and enlarged text", async ({ + page, + app, + }) => { + await page.addInitScript(() => { + window.fixtureSidebarRecovery = true; + localStorage.setItem("buzz-appearance.v1", "system"); + localStorage.setItem( + "buzzodz.plugins.v1", + JSON.stringify({ + version: 2, + enabled: { "fixture.page-placement": true }, + }), + ); + }); + await page.goto(app.origin); + await openPage(page, "Messages"); + await page.evaluate(() => { + document.documentElement.style.fontSize = "200%"; + }); + for (const [width, height, theme] of [ + [1440, 500, "light"], + [390, 844, "dark"], + ]) { + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize({ width, height }); + await page.evaluate(() => { + window.fixtureSidebarRecovery = true; + }); + await openPage(page, "Sidebar playground"); + if (width === 390) + await page.getByRole("button", { name: "Show navigation" }).click(); + const fallback = page.getByRole("complementary", { + name: "Sidebar playground sidebar", + }); + await expect(fallback.getByRole("alert")).toHaveText( + "This page’s sidebar is unavailable.", + ); + await expect( + page.getByRole("heading", { name: "Sidebar playground", exact: true }), + ).toBeVisible(); + const retry = fallback.getByRole("button", { + name: "Sidebar playground", + exact: true, + }); + const scrollMetrics = () => + retry.evaluate((element) => { + for ( + let parent = element.parentElement; + parent; + parent = parent.parentElement + ) { + if ( + !/^(auto|scroll)$/.test(getComputedStyle(parent).overflowY) || + parent.scrollHeight <= parent.clientHeight + ) + continue; + const region = parent.getBoundingClientRect(); + const row = element.getBoundingClientRect(); + return { + top: parent.scrollTop, + reachable: row.top >= region.top && row.bottom <= region.bottom, + }; + } + return null; + }); + await expect.poll(scrollMetrics).not.toBeNull(); + await fallback.hover(); + await page.mouse.wheel(0, 2000); + await expect.poll(scrollMetrics).toMatchObject({ reachable: true }); + expect((await scrollMetrics()).top).toBeGreaterThan(0); + await retry.focus(); + await expect(retry).toBeFocused(); + await page.evaluate(() => { + window.fixtureSidebarRecovery = false; + }); + await retry.press("Enter"); + await expect(fallback).toHaveCount(0); + if (width === 390) + await page.getByRole("button", { name: "Show navigation" }).click(); + await expect( + page.getByRole("button", { name: "Fixture sidebar action" }), + ).toBeVisible(); + } + }); +}); + test("header pages remain reachable without overlap through scale and resize", async ({ page, app, diff --git a/tests/browser/plugin-fixtures.tsx b/tests/browser/plugin-fixtures.tsx index b7819b7f8..1089034e8 100644 --- a/tests/browser/plugin-fixtures.tsx +++ b/tests/browser/plugin-fixtures.tsx @@ -55,6 +55,7 @@ declare global { fixtureNavigation?: Navigation; fixtureNotify?: (input: NotificationInput) => Promise; fixturePageBroken?: boolean; + fixtureSidebarRecovery?: boolean; delayFixture?: { started: boolean; release?: () => void }; stopFixtureDependency?: () => Promise; startFixtureDependency?: () => void; @@ -142,6 +143,39 @@ export const fixturePlugins: readonly BundledPlugin[] = [ primary: true, component: () =>

{title}

, }); + ctx.pages.register({ + id: "sidebar", + title: "Sidebar playground", + primary: true, + component: () =>

Sidebar playground

, + sidebar: ({ target, children }) => { + if (window.fixtureSidebarRecovery) + throw new Error("Fixture page render failure"); + return ( +
+ +
+ ); + }, + }); + if (window.fixtureSidebarRecovery) + for (let index = 1; index <= 12; index += 1) + ctx.pages.register({ + id: `recovery-${index}`, + title: `Recovery entry ${String(index).padStart(2, "0")}`, + primary: true, + component: () =>

Recovery destination

, + }); }, }, },