+
+ {/* This page is deliberately library-neutral: it is the page that helps
+ * you pick one, so the picker opens with nothing selected. */}
+
+
+
-
+
-
-
- >
+
+
+
);
}
diff --git a/apps/website/src/components/docs/DocsControlPlane.spec.tsx b/apps/website/src/components/docs/DocsControlPlane.spec.tsx
index 2ef5dbb4b..96c17f484 100644
--- a/apps/website/src/components/docs/DocsControlPlane.spec.tsx
+++ b/apps/website/src/components/docs/DocsControlPlane.spec.tsx
@@ -232,6 +232,45 @@ describe('DocsControlPlane', () => {
});
});
+describe('DocsControlPlane — library-neutral', () => {
+ it('states only what it knows in Scope', () => {
+ render(
+ ,
+ );
+
+ const scope = screen.getByRole('heading', { name: 'Scope' }).closest('section');
+ if (!scope) throw new Error('Expected Scope section');
+ expect(within(scope).getByText('Choosing an adapter')).toBeTruthy();
+ expect(within(scope).queryByText('LangGraph')).toBeNull();
+ expect(within(scope).queryByText('Getting Started')).toBeNull();
+ });
+
+ it('offers an unselected picker and no section tree', () => {
+ render(
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Choose a library' });
+ fireEvent.click(trigger);
+ const items = screen.getAllByRole('menuitemradio');
+ expect(items.length).toBeGreaterThan(0);
+ expect(items.every((i) => i.getAttribute('aria-checked') === 'false')).toBe(true);
+
+ // No library means there is no section tree to show.
+ expect(screen.queryByRole('button', { name: 'Getting Started' })).toBeNull();
+ });
+});
+
describe('DocsContextContent', () => {
it('reuses the same sentence-case navigation content for mobile', () => {
render(
diff --git a/apps/website/src/components/docs/DocsControlPlane.tsx b/apps/website/src/components/docs/DocsControlPlane.tsx
index b4c8b62b7..8c15ffc47 100644
--- a/apps/website/src/components/docs/DocsControlPlane.tsx
+++ b/apps/website/src/components/docs/DocsControlPlane.tsx
@@ -28,7 +28,8 @@ import { buildCockpitModeHref } from '../../lib/cockpit-links';
import { DocsNavigation } from './DocsSidebar';
export interface DocsControlPlaneProps {
- activeLibrary: LibraryId;
+ /** `null` on a library-neutral docs page, e.g. /docs/choosing-an-adapter. */
+ activeLibrary: LibraryId | null;
activeSection: string;
activeSlug: string;
pageTitle: string;
@@ -46,8 +47,8 @@ export function DocsContextContent({
onNavigate,
}: DocsControlPlaneProps & { mobile?: boolean; onNavigate?: () => void }) {
const preferences = useControlPlanePreferences('docs');
- const library = getLibraryConfig(activeLibrary);
- const section = getDocsSection(activeLibrary, activeSection);
+ const library = activeLibrary ? getLibraryConfig(activeLibrary) : undefined;
+ const section = activeLibrary ? getDocsSection(activeLibrary, activeSection) : undefined;
const openSearch = () => {
if (!mobile) {
dispatchSearch();
@@ -65,8 +66,11 @@ export function DocsContextContent({
- {library?.title ?? activeLibrary}
- {section?.title ?? activeSection}
+ {/* A neutral page has no library and no section. Say only what is
+ * true — inventing them is how the mobile drawer came to claim
+ * "LangGraph / Getting Started" on the adapter-comparison page. */}
+ {library?.title ?? 'Docs'}
+ {library && section ? {section.title} : null}
{pageTitle}
);
}
diff --git a/apps/website/src/components/docs/MdxRenderer.tsx b/apps/website/src/components/docs/MdxRenderer.tsx
index e32d049a6..9a6d26bce 100644
--- a/apps/website/src/components/docs/MdxRenderer.tsx
+++ b/apps/website/src/components/docs/MdxRenderer.tsx
@@ -10,7 +10,6 @@ import { FeatureChips } from './mdx/FeatureChips';
import { mdxHeadingComponents } from './mdx/headings';
import { ArchFlowDiagram } from './ArchFlowDiagram';
import { AgUiArchDiagram } from './AgUiArchDiagram';
-import { type LibraryId } from '../../lib/docs-config';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
@@ -79,13 +78,9 @@ const rehypeOptions = {
interface MdxRendererProps {
source: string;
- library: LibraryId;
- section: string;
- slug: string;
- title: string;
}
-export function MdxRenderer({ source, library, section, slug, title }: MdxRendererProps) {
+export function MdxRenderer({ source }: MdxRendererProps) {
return (
({
+const { trackCtaClick, pathnameRef } = vi.hoisted(() => ({
trackCtaClick: vi.fn(),
+ pathnameRef: { current: '/docs/langgraph/guides/streaming' },
}));
vi.mock('next/navigation', () => ({
- usePathname: () => '/docs/langgraph/guides/streaming',
+ usePathname: () => pathnameRef.current,
useRouter: () => ({ push: vi.fn() }),
}));
@@ -22,6 +23,27 @@ describe('Docs mobile navigation', () => {
beforeEach(() => {
window.localStorage.clear();
trackCtaClick.mockClear();
+ pathnameRef.current = '/docs/langgraph/guides/streaming';
+ });
+
+ it('does not invent a library on a library-neutral docs page', () => {
+ pathnameRef.current = '/docs/choosing-an-adapter';
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Open menu' }));
+ const dialog = screen.getByRole('dialog', { name: 'Mobile navigation' });
+
+ // `/docs/choosing-an-adapter` has no library segment. Falling back to
+ // 'langgraph' made the Scope card read "LangGraph / Getting Started /
+ // Documentation" — three fabrications in the one card whose job is saying
+ // where you are.
+ const scope = within(dialog).getByRole('heading', { name: 'Scope' }).closest('section');
+ if (!scope) throw new Error('Expected a Scope section');
+ expect(within(scope).queryByText('LangGraph')).toBeNull();
+ expect(within(scope).queryByText('Getting Started')).toBeNull();
+ expect(within(scope).getByText('Choosing an adapter')).toBeTruthy();
+
+ expect(within(dialog).queryByRole('button', { name: 'LangGraph' })).toBeNull();
+ expect(within(dialog).getByRole('button', { name: 'Choose a library' })).toBeTruthy();
});
it('uses the existing header trigger for the control-plane Docs drawer', () => {
diff --git a/apps/website/src/components/shared/Nav.tsx b/apps/website/src/components/shared/Nav.tsx
index ecb47d0dc..f507e066a 100644
--- a/apps/website/src/components/shared/Nav.tsx
+++ b/apps/website/src/components/shared/Nav.tsx
@@ -2,7 +2,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { findDocsPage, getLibraryConfig, type LibraryId } from '../../lib/docs-config';
+import {
+ findDocsPage,
+ getLibraryConfig,
+ specialDocsPages,
+ type LibraryId,
+} from '../../lib/docs-config';
import { trackCtaClick, trackExternalLinkClick } from '../../lib/analytics/client';
import type { AnalyticsLibrary } from '../../lib/analytics/events';
import { LogoMark } from '../ui/LogoMark';
@@ -17,7 +22,7 @@ const links = [
{ label: 'Examples', href: 'https://cockpit.threadplane.ai', external: true },
];
-const toAnalyticsLibrary = (library: LibraryId): AnalyticsLibrary => {
+const toAnalyticsLibrary = (library: LibraryId | null): AnalyticsLibrary => {
switch (library) {
case 'langgraph':
case 'render':
@@ -95,8 +100,15 @@ export function Nav() {
const activeLibrary = isDocsPage && pathParts.length >= 2 ? pathParts[1] : '';
const activeSection = isDocsPage && pathParts.length >= 3 ? pathParts[2] : '';
const activeSlug = isDocsPage && pathParts.length >= 4 ? pathParts[3] : '';
- const docsLibrary = (getLibraryConfig(activeLibrary)?.id ?? 'langgraph') as LibraryId;
- const docsPageTitle = findDocsPage(activeLibrary, activeSection, activeSlug)?.title ?? 'Documentation';
+ // A docs URL without a library segment (e.g. /docs/choosing-an-adapter) is
+ // library-neutral. Defaulting to a library here made the drawer claim the
+ // reader was inside LangGraph's docs.
+ const docsLibrary = (getLibraryConfig(activeLibrary)?.id ?? null) as LibraryId | null;
+ const specialDocsPage = specialDocsPages.find((page) => page.path === pathname);
+ const docsPageTitle =
+ findDocsPage(activeLibrary, activeSection, activeSlug)?.title ??
+ specialDocsPage?.title ??
+ 'Documentation';
const mobileTriggerRef = useRef(null);
const mobileDialogRef = useRef(null);
const closeMobileMenu = useCallback(() => {
diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css
index 3b0c9f41b..8a454d931 100644
--- a/apps/website/src/styles/docs.css
+++ b/apps/website/src/styles/docs.css
@@ -1730,6 +1730,11 @@
font-family: var(--font-inter);
font-size: 13px;
}
+/* Library-neutral pages: the picker is an invitation, not a statement. */
+.docs-sidebar-lib-trigger-label[data-placeholder] {
+ color: var(--color-text-muted);
+ font-weight: 400;
+}
.docs-sidebar-lib-trigger > svg { transition: transform 150ms ease; }
.docs-sidebar-lib-trigger > svg[data-open] { transform: rotate(180deg); }
.docs-sidebar-lib-menu {
diff --git a/docs/superpowers/specs/2026-09-01-docs-library-neutral-pages-design.md b/docs/superpowers/specs/2026-09-01-docs-library-neutral-pages-design.md
new file mode 100644
index 000000000..5954edaf8
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-01-docs-library-neutral-pages-design.md
@@ -0,0 +1,139 @@
+# Library-neutral docs pages
+
+**Date:** 2026-09-01
+**Scope:** `/docs/choosing-an-adapter` and the control plane's missing
+"no library selected" state
+**Status:** approved, ready to implement
+
+## Context
+
+Follow-ups recorded during the adapter-picker refresh (#911). Re-checked against
+`main` before writing this, and both moved:
+
+- **"There is no docs nav below `lg`" is already fixed.** #892 added a mobile
+ drawer with Site/Docs tabs that renders `DocsContextContent`. The original
+ note was taken against the pre-#892 tree.
+- **"`/docs` has no control plane" is not a defect.** `/docs` is a designed
+ landing page — "Start building with Threadplane", pick-your-backend cards.
+ It is the front door; a sidebar would damage it. Only
+ `/docs/choosing-an-adapter` is a content page missing its shell.
+
+What the re-check *did* surface is a worse bug than the one originally noted.
+
+## Problems
+
+### 1. The mobile drawer fabricates a location
+
+On `/docs/choosing-an-adapter` at 375px the Scope card reads:
+
+> **LangGraph** / Getting Started / **Documentation**
+
+Three fabrications in the one card whose job is telling you where you are.
+`Nav.tsx:95` derives `activeLibrary` from `pathParts[1]`, which here is
+`"choosing-an-adapter"` — not a library. `getLibraryConfig()` returns
+undefined, and line 98 falls back to `'langgraph'`. The drawer then shows
+LangGraph's picker, LangGraph's whole section tree, and the page title
+`'Documentation'`.
+
+The failure mode is silence: it renders something plausible.
+
+### 2. The page has no control plane
+
+Only `[library]/[section]/[slug]` renders `DocsControlPlane`. Following the
+"Choosing an adapter" link from the sidebar drops the reader into a page with
+no nav out.
+
+### 3. The section has no accessible name
+
+`aria-labelledby="choosing-an-adapter-heading"` points at an empty `
`.
+Verified: the target's text content is `""`.
+
+### 4. A 144px dead gap
+
+Measured, between the eyebrow and the H1 — an empty hero `Section` stacked
+above the content `Section`.
+
+### 5. The MDX pipeline is duplicated
+
+`choosing-an-adapter/page.tsx` carries ~60 lines of `mdxComponents`,
+`rehypeOptions` and prose token styles that already exist in `MdxRenderer`,
+which the `[slug]` route uses.
+
+Problems 2–5 are all symptoms of one cause: the page is bespoke and drifted
+from the shell every other docs page uses.
+
+**Not a problem:** a hydration error seen while investigating was an artifact of
+resizing the tab mid-hydration. A fresh tab logs no console errors on either
+page. Not pursued.
+
+## Design
+
+### Nullable library
+
+`DocsControlPlaneProps.activeLibrary` and `DocsNavigationProps.activeLibrary`
+become `LibraryId | null`. `null` means library-neutral — a state the control
+plane has never had, and whose absence produced problem 1.
+
+### The neutral states
+
+| Element | With a library | Neutral |
+| --- | --- | --- |
+| Scope | library / section / page | `Docs` / page |
+| Picker trigger | mark + library name | `Choose a library`, muted, no mark |
+| Picker menu | current entry `aria-checked` | nothing checked |
+| Learn | special links + picker + sections | special links + picker only |
+
+The neutral picker label is coherent on this page in particular: the reader is
+literally on the page that helps them choose.
+
+### `Nav.tsx`
+
+- `docsLibrary`: `getLibraryConfig(activeLibrary)?.id ?? null` — the
+ `?? 'langgraph'` fallback is the bug.
+- `docsPageTitle`: look up `specialDocsPages` by pathname before falling back to
+ `'Documentation'`.
+
+This corrects the drawer on every library-neutral route, not just this one.
+
+### The page
+
+`choosing-an-adapter/page.tsx` adopts the `docs-shell-page` layout used by the
+`[slug]` route, with ``, and:
+
+- **Deletes the empty hero `Section`.** Removes the 144px gap and the dangling
+ `aria-labelledby` target in one move; the section takes a real `aria-label`.
+- **Replaces its MDX pipeline with ``.**
+
+### `MdxRenderer`
+
+Props reduce to `{ source }`. `library`, `section`, `slug` and `title` are
+accepted and never read — they are four of the website's existing lint
+warnings. The `[slug]` call site updates accordingly.
+
+### Not needed
+
+The rail's Run/Code/API links already fall back to the cockpit root when
+`resolveCockpitIdentity` finds no mapping, which is the case for all but five
+docs pages. A null library needs no special handling there.
+
+## Testing
+
+Must fail against `main`:
+
+1. **`Nav` on `/docs/choosing-an-adapter`** — Scope does not say LangGraph, and
+ the page title is "Choosing an adapter". This is problem 1.
+
+New coverage:
+
+2. **Neutral control plane** — no library or section line in Scope, picker reads
+ "Choose a library", no section groups, nothing `aria-checked`.
+3. **The page renders the control plane and its H1**, with no empty
+ `aria-labelledby` target.
+4. **A library page is unchanged** — Scope still shows library and section, and
+ the picker still shows the current library checked. Guards against the
+ nullable change quietly degrading the normal path.
+
+## Out of scope
+
+`/docs` keeps its landing-page treatment. If it should ever gain the control
+plane that is a separate design question about the front door, not a defect.