From b74109e38a45c1496cfe1f15f4d53de61cf49cdf Mon Sep 17 00:00:00 2001 From: ruby childs Date: Tue, 8 Sep 2026 20:33:56 -0400 Subject: [PATCH 1/3] fix(desktop): keep a new task button when the sidebar collapses The sidebar holds every create affordance: the floating "+" and the "New task" row. Collapsing the sidebar, or landing on a destination that draws no sidebar, leaves the keyboard shortcut as the only way to start a task. Show a "New task" button in the title bar beside the sidebar toggle whenever the sidebar body is off screen. It files into the scoped channel, the same as the shortcut and the sidebar "+". Generated-By: PostHog Desktop Task-Id: ae645e0e-1651-4060-b869-5e751f874793 --- .../packages/ui/src/router/routes/__root.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/products/desktop/packages/ui/src/router/routes/__root.tsx b/products/desktop/packages/ui/src/router/routes/__root.tsx index 8b07802b9a7d..727ed6212308 100644 --- a/products/desktop/packages/ui/src/router/routes/__root.tsx +++ b/products/desktop/packages/ui/src/router/routes/__root.tsx @@ -2,9 +2,18 @@ import { ArrowSquareOut, CaretLeftIcon, CaretRightIcon, + PlusIcon, } from "@phosphor-icons/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; -import { Button, ButtonGroup, cn } from "@posthog/quill"; +import { + Button, + ButtonGroup, + cn, + Kbd, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; import { BILLING_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { isContentlessTask } from "@posthog/shared/domain-types"; @@ -37,6 +46,10 @@ import { usePostHogWebFeedbackStore } from "@posthog/ui/features/canvas/stores/p import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; import { ConnectivityBanner } from "@posthog/ui/features/connectivity/ConnectivityBanner"; import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; import { useOpenTargetDeepLink } from "@posthog/ui/features/deep-links/useOpenTargetDeepLink"; @@ -224,6 +237,15 @@ function RootLayout() { cancelSidebarPeek(); toggleSidebar(); }; + // Files the task into the channel you are in, the same as the sidebar "+" + // and the new-task shortcut. openTaskInput reads that scope itself. + const handleNewTask = (): void => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "title_bar", + }); + openTaskInput(); + }; const sidebarData = useSidebarData({ activeView: view }); const visualTaskOrder = useVisualTaskOrder(sidebarData); @@ -357,6 +379,29 @@ function RootLayout() { )} + {/* The sidebar holds every "+": its create button and the + "New task" row. A collapsed sidebar, or a destination that + draws no sidebar, takes them all away. Put one back beside + the control that hid them. */} + {!sidebarDocked && ( + + + + + } + /> + + New task + {formatHotkey(SHORTCUTS.NEW_TASK)} + + + )} {localWorkspaces && ( From 54f8176e5525cfd94490893d3130bda6fc5d5c48 Mon Sep 17 00:00:00 2001 From: Ruby Date: Fri, 11 Sep 2026 18:21:09 -0700 Subject: [PATCH 2/3] fix(desktop): keep the create button in the nav rail The orange create button lived only in the sidebar, so collapsing the sidebar took away the last mouse route to a new task. It now sits in the nav rail above Search on every destination, the floating copies in the space pane and the spaces list are gone, and the Spaces heading gets a "+" for a new space. Co-Authored-By: Claude Fable 5.1 --- .../canvas/components/ChannelItemsPane.tsx | 3 - .../canvas/components/ChannelSidebar.test.tsx | 4 - .../canvas/components/ChannelSidebar.tsx | 2 - .../canvas/components/ChannelsFab.tsx | 37 ++++-- .../canvas/components/ChannelsList.test.tsx | 17 +++ .../canvas/components/ChannelsList.tsx | 117 ++++++++++++------ .../canvas/components/ChannelsSidebar.tsx | 1 - .../canvas/components/NavRail.test.tsx | 54 ++++++++ .../features/canvas/components/NavRail.tsx | 12 ++ .../packages/ui/src/router/routes/__root.tsx | 47 +------ 10 files changed, 190 insertions(+), 104 deletions(-) diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx index a98265526035..d186d07284a8 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx @@ -133,7 +133,6 @@ export function ChannelItemsPane({ cap, channelIdFor, emptyState, - overlay, searchLabel = "Search sessions", }: { items: readonly ChannelItemModel[]; @@ -147,7 +146,6 @@ export function ChannelItemsPane({ cap?: number; channelIdFor?: (item: ChannelItemModel) => string | undefined; emptyState: ReactNode; - overlay?: ReactNode; searchLabel?: string; }) { const [editingTaskId, setEditingTaskId] = useState(null); @@ -415,7 +413,6 @@ export function ChannelItemsPane({ ))} - {overlay} ({ vi.mock("@posthog/ui/features/canvas/components/ChannelBackRow", () => ({ ChannelBackRow: () => null, })); -vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ - ChannelsFab: () => null, -})); - // The row menu's spaces list reaches for a QueryClient the unit test has no // stack for. Stubbed at the module boundary, as ShellLayout.test.tsx does for // the same reason. diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 4e7e3e5514ee..5226fb83bca5 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -17,7 +17,6 @@ import { import { LOOPS_FLAG } from "@posthog/shared"; import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemsPane } from "@posthog/ui/features/canvas/components/ChannelItemsPane"; -import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { type ChannelPageKey, channelPageLabel, @@ -243,7 +242,6 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { cap={RECENTS_CAP} channelIdFor={() => channelId} emptyState={} - overlay={} searchLabel={ tab === "canvas" ? "Search canvases" : "Search sessions" } diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx index d14987da5242..222832b601b6 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -31,15 +31,22 @@ import { track } from "@posthog/ui/shell/analytics"; import { useState } from "react"; /** - * The create affordance for the Channels space, floated over the bottom-right - * of whichever sidebar pane is showing. + * The create button. Given a channel it creates inside it (task, canvas); from + * the list it creates a channel, which has no other entry point. * - * The same button on both panes, so "create" is always the same corner: given a - * channel it creates inside it (task, canvas); from the list it creates a - * channel, which has no other entry point. + * On the spaces layout it sits in the nav rail (`placement="rail"`), which + * every destination keeps on screen whether or not it draws a sidebar. Off the + * layout it floats over the bottom-right of the channel list. */ -export function ChannelsFab({ channelId }: { channelId?: string }) { +export function ChannelsFab({ + channelId, + placement = "floating", +}: { + channelId?: string; + placement?: "floating" | "rail"; +}) { const channelsLayout = useChannelsLayout(); + const inRail = placement === "rail"; const [modalOpen, setModalOpen] = useState(false); const hasDraft = useDraftStore((state) => Object.entries(state.drafts).some( @@ -51,7 +58,7 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { const newTask = () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "new_task_open", - surface: "sidebar", + surface: inRail ? "nav" : "sidebar", channel_id: channelId, }); // In a channel the task is filed there; from the list it's whatever the @@ -86,18 +93,22 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { const trigger = ( ); const tooltip = ( - + {channelsLayout ? ( <> {/* The draft dot needs saying out loud, and the button is where @@ -128,8 +139,8 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { {tooltip} {/* Off the layout this is the list's only menu, and "New channel" diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx index 5cd64f556d23..779a447435fa 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -34,6 +34,10 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/components/CreateChannelModal", () => ({ + CreateChannelModal: ({ open }: { open: boolean }) => + open ?
New space dialog
: null, +})); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ useChannelsLayout: () => mocks.channelsLayout, })); @@ -298,6 +302,19 @@ describe("ChannelsList", () => { expect(screen.queryByRole("heading", { name: "Spaces" })).toBeNull(); expect(screen.getByText("Channels")).toBeTruthy(); }); + + // The heading's "+" is the list's way to a new space now that nothing + // floats over it; off the layout the floating button still offers one. + it("starts a new space from the Spaces heading on the layout only", async () => { + const view = renderList(); + await userEvent.click(screen.getByRole("button", { name: "New space" })); + expect(screen.getByRole("dialog")).toHaveTextContent("New space dialog"); + + view.unmount(); + mocks.channelsLayout = false; + renderList(); + expect(screen.queryByRole("button", { name: "New space" })).toBeNull(); + }); }); describe("search", () => { diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx index f498083df005..26f29b70a31a 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -57,6 +57,7 @@ import { ChannelItemHoverCard, SpaceHoverCard, } from "@posthog/ui/features/canvas/components/ChannelItemHoverCard"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import type { ChannelActionItem } from "@posthog/ui/features/canvas/components/channelActions"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { PresenceAvatars } from "@posthog/ui/features/canvas/components/PresenceAvatars"; @@ -1660,6 +1661,35 @@ const CHANNELS_SECTION_ID = "channels:all"; /** A heading's identity in the flat list, kept clear of any channel's id. */ const sectionValue = (sectionId: string) => `section:${sectionId}`; +// The Spaces heading's "+": the same outline plus a space row shows for a new +// task, here starting a new space. It owns the create dialog so the heading +// stays a plain toggle. +function NewSpaceButton() { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(true)} + > + + + } + /> + New space + + + + ); +} + // A collapsible sidebar group ("Starred" / "Channels"). Base UI directly rather // than quill's Collapsible: quill styles its trigger as a button (which fought // the label styling) and animates the panel height (which janked on a list this @@ -1675,11 +1705,18 @@ function ChannelGroup({ flat, keepMounted = true, asOption = false, + trailing, children, }: { sectionId: string; label: string; className?: string; + /** + * A control on the heading's right, shown on hover like a row's own. Sits + * beside the trigger rather than in it: the heading is a button, and a + * button can't hold another. + */ + trailing?: ReactNode; /** Layout-only: removes the legacy tree indent; rows apply their own inset. */ flat?: boolean; /** @@ -1711,41 +1748,48 @@ function ChannelGroup({ }} className={cn(className, "mb-2")} > - {/* MenuLabel carries the sidebar's label styling; `render` keeps it a +
+ {/* MenuLabel carries the sidebar's label styling; `render` keeps it a real button so the whole row is clickable. Wrapped in an option when the keyboard walks the list, so the heading is a stop on the way down rather than a gap the highlight jumps over. */} - span]:w-full [&>span]:items-center", - )} - render={ - asOption ? ( - } />} - /> - ) : ( - } /> - ) - } - > - {label} - {/* On the right, because the heading's name is the left edge every row + span]:w-full [&>span]:items-center", + )} + render={ + asOption ? ( + } />} + /> + ) : ( + } /> + ) + } + > + {label} + {/* On the right, because the heading's name is the left edge every row beneath it lines up to. Always drawn: which way the section is, is the one thing this row has to say. */} - {isOpen ? ( - - ) : ( - + {isOpen ? ( + + ) : ( + + )} + + {trailing && ( +
+ {trailing} +
)} -
+
{/* Stay mounted while collapsed. Every row builds a context menu, a dropdown, a tooltip and two dialogs up front, so unmounting on close makes each expand rebuild the lot (~940ms for 46 channels, vs ~80ms @@ -1760,8 +1804,9 @@ function ChannelGroup({ // The channel list is the list pane of the sidebar slider. The personal channel // is pinned at the top; starred channels surface in their own section // so the ones you use most stay in reach; the rest sit under a "Channels" -// label. Creating anything goes through the floating ChannelsFab, mounted by -// the sidebar outside this scroll region. +// label. Creating anything goes through the create button: in the nav rail on +// the spaces layout, otherwise the floating ChannelsFab the sidebar mounts +// outside this scroll region. export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); // ChannelHotkeys owns the keys these slots describe; sharing the derivation @@ -2061,6 +2106,8 @@ export function ChannelsList() { flat={channelsLayout} keepMounted={!channelsLayout} asOption={channelsLayout} + // Off the layout the floating create button already offers a channel. + trailing={channelsLayout ? : undefined} > {!isLoading && channels.length === 0 && ( @@ -2086,10 +2133,10 @@ export function ChannelsList() { ); - // Bottom padding clears the floating create button (ChannelsFab), so the last - // channel stays reachable at full scroll. - const scrollClass = - "scroll-mask-8 min-h-0 flex-1 overflow-y-auto px-2 pt-2 pb-16"; + // Off the layout, bottom padding clears the floating create button + // (ChannelsFab) so the last channel stays reachable at full scroll. The + // layout keeps that button in the rail, so the list needs no room for it. + const scrollClass = `scroll-mask-8 min-h-0 flex-1 overflow-y-auto px-2 pt-2 ${channelsLayout ? "pb-2" : "pb-16"}`; // quill sizes its list as a popup — a ~250px cap and its own 4px padding — // and ships it unlayered, so plain utilities lose to it however they're // ordered. Here the list *is* the pane, so the cap has to go and the pane's diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index a2603abda259..4ce608039607 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -116,7 +116,6 @@ function ChannelPanes({ >
-
{channelId && ( diff --git a/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx index c1cd3b948e09..71df67c2fc0a 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx @@ -84,6 +84,22 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); vi.mock("@posthog/ui/features/canvas/components/ActivityHoverCard", () => ({ ActivityHoverCard: () =>
Recent activity card
, })); +vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ + ChannelsFab: ({ + channelId, + placement, + }: { + channelId?: string; + placement?: string; + }) => ( + - {/* The sidebar holds every "+": its create button and the - "New task" row. A collapsed sidebar, or a destination that - draws no sidebar, takes them all away. Put one back beside - the control that hid them. */} - {!sidebarDocked && ( - - - - - } - /> - - New task - {formatHotkey(SHORTCUTS.NEW_TASK)} - - - )} {localWorkspaces && ( From 466b2c40d20ce6691e3ecf6735b332fac2a35572 Mon Sep 17 00:00:00 2001 From: Ruby Date: Tue, 15 Sep 2026 23:17:47 -0400 Subject: [PATCH 3/3] fix(desktop): address review on the rail create button - Drop the important bottom padding on the spaces list; nothing floats over its end on the layout any more. - Offer "New space" from the no-match state, since filtering hides the Spaces heading and its "+". - Trim comments. Co-Authored-By: Claude Fable 5.1 --- .../canvas/components/ChannelsList.test.tsx | 10 +++ .../canvas/components/ChannelsList.tsx | 84 +++++++++++-------- .../features/canvas/components/NavRail.tsx | 8 +- 3 files changed, 63 insertions(+), 39 deletions(-) diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx index 0ba3975f3607..9a5d47db366c 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -361,6 +361,16 @@ describe("ChannelsList", () => { ); }); + it("offers a new space when nothing matches", async () => { + const user = userEvent.setup(); + renderList(); + + await user.type(screen.getByLabelText("Search spaces"), "zzz"); + await user.click(screen.getByRole("button", { name: "New space" })); + + expect(screen.getByRole("dialog")).toHaveTextContent("New space dialog"); + }); + it("says so when nothing matches", async () => { const user = userEvent.setup(); renderList(); diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx index 791b6eb48d6b..f956e74be5fb 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -1690,30 +1690,45 @@ const CHANNELS_SECTION_ID = "channels:all"; /** A heading's identity in the flat list, kept clear of any channel's id. */ const sectionValue = (sectionId: string) => `section:${sectionId}`; -// The Spaces heading's "+": the same outline plus a space row shows for a new -// task, here starting a new space. It owns the create dialog so the heading -// stays a plain toggle. -function NewSpaceButton() { +// Starts a new space. On the Spaces heading it is the same hover-revealed +// plus a space row shows; inline (the no-match state) it is a labelled button. +function NewSpaceButton({ + appearance = "heading", +}: { + appearance?: "heading" | "inline"; +}) { const [open, setOpen] = useState(false); return ( <> - - setOpen(true)} - > - - - } - /> - New space - + {appearance === "inline" ? ( + + ) : ( + + setOpen(true)} + > + + + } + /> + New space + + )} ); @@ -1740,11 +1755,8 @@ function ChannelGroup({ sectionId: string; label: string; className?: string; - /** - * A control on the heading's right, shown on hover like a row's own. Sits - * beside the trigger rather than in it: the heading is a button, and a - * button can't hold another. - */ + /** A control on the heading's right. Beside the trigger, not in it: the + * heading is a button, and a button can't hold another. */ trailing?: ReactNode; /** Layout-only: removes the legacy tree indent; rows apply their own inset. */ flat?: boolean; @@ -1833,9 +1845,8 @@ function ChannelGroup({ // The channel list is the list pane of the sidebar slider. The personal channel // is pinned at the top; starred channels surface in their own section // so the ones you use most stay in reach; the rest sit under a "Channels" -// label. Creating anything goes through the create button: in the nav rail on -// the spaces layout, otherwise the floating ChannelsFab the sidebar mounts -// outside this scroll region. +// label. Creating a task goes through the create button in the nav rail (off +// the layout, the floating ChannelsFab the sidebar mounts outside this list). export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); // ChannelHotkeys owns the keys these slots describe; sharing the derivation @@ -2090,10 +2101,13 @@ export function ChannelsList() { /> ))} {noMatches && ( - + No {channelsLayout ? "spaces" : "channels"} match “{query.trim()}”. + {/* Filtering hides the Spaces heading and its "+", so the space you + searched for and didn't find can still be made from here. */} + )} @@ -2162,17 +2176,19 @@ export function ChannelsList() { ); - // Off the layout, bottom padding clears the floating create button - // (ChannelsFab) so the last channel stays reachable at full scroll. The - // layout keeps that button in the rail, so the list needs no room for it. - const scrollClass = `scroll-mask-8 min-h-0 flex-1 overflow-y-auto px-2 pt-2 ${channelsLayout ? "pb-2" : "pb-16"}`; + // Bottom padding clears the floating create button (ChannelsFab), so the last + // channel stays reachable at full scroll. + const scrollClass = + "scroll-mask-8 min-h-0 flex-1 overflow-y-auto px-2 pt-2 pb-16"; // quill sizes its list as a popup — a ~250px cap and its own 4px padding — // and ships it unlayered, so plain utilities lose to it however they're // ordered. Here the list *is* the pane, so the cap has to go and the pane's // own padding has to win: `!` is what outranks an unlayered rule. const listClass = cn( "sidebar-autocomplete-tree flex flex-col gap-px", - "!max-h-none !px-2 !pt-2 !pb-16 scroll-py-8", + // The layout keeps the create button in the rail, so nothing floats over + // the list's end and it needs no clearance there. + "!max-h-none !px-2 !pt-2 !pb-2 scroll-py-8", scrollClass, ); diff --git a/products/desktop/packages/ui/src/features/canvas/components/NavRail.tsx b/products/desktop/packages/ui/src/features/canvas/components/NavRail.tsx index 11c58cabeb56..cd0833f12666 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/NavRail.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/NavRail.tsx @@ -230,8 +230,7 @@ function NavRailImpl() { // light a destination the screen isn't on. const railPane = useRailPane(); const toggleCommandMenu = useCommandMenuStore((s) => s.toggle); - // The create button files into the space you are in, the same as the - // sidebar's own button and the new-task shortcut. + // So the create button files into the space you are in, like the shortcut. const currentChannelId = useCurrentChannelStore((s) => s.currentChannelId); const pick = @@ -317,9 +316,8 @@ function NavRailImpl() { {topDestinations.map(renderDestination)}
{bottomDestinations.map(renderDestination)} - {/* The rail is the one column every destination keeps, sidebar or - not, so the create button lives here to be reachable from all - of them. */} + {/* Every destination keeps the rail, sidebar or not, so the create + button is reachable from all of them here. */}