From 0cd507c7ffe3b9e4860ca389ec5c1a28ef6cc6f5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 31 Aug 2026 21:14:42 -0700 Subject: [PATCH 1/2] docs: spec for library-neutral docs pages Records the design for /docs/choosing-an-adapter and the control plane's missing "no library selected" state. Also records that one of the two follow-ups behind this work was already fixed by #892, and that /docs is a landing page rather than a page missing its shell. Co-Authored-By: Claude Opus 5 --- ...09-01-docs-library-neutral-pages-design.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-01-docs-library-neutral-pages-design.md 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. From 07fd4100eb3c09beb61bca41b4afed3d05db3032 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 31 Aug 2026 21:21:48 -0700 Subject: [PATCH 2/2] fix(website): give library-neutral docs pages an honest control plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On /docs/choosing-an-adapter the mobile drawer's Scope card read "LangGraph / Getting Started / Documentation" — three fabrications in the one card whose job is saying where you are. Nav derives the library from the second path segment, which on that URL is "choosing-an-adapter"; getLibraryConfig returns undefined and the code fell back to 'langgraph', so the drawer showed LangGraph's picker and its whole section tree. The cause is that the control plane had no "no library selected" state, so every caller had to invent one. Adds it: - activeLibrary is now LibraryId | null through DocsControlPlane and DocsNavigation. Neutral pages show "Docs / " in Scope, a "Choose a library" picker with nothing checked, and no section tree. - Nav stops defaulting to langgraph and resolves special-page titles, which corrects the drawer on every library-neutral route. /docs/choosing-an-adapter was also bespoke, and that drift is what produced the rest of its symptoms. It now uses the same shell as every other docs page, which: - gives it the control plane it never had, - deletes an empty hero Section that opened a measured 144px gap above the H1 and whose empty div was the target of aria-labelledby, leaving the section with no accessible name, - replaces ~60 duplicated lines of MDX pipeline with MdxRenderer. MdxRenderer's library/section/slug/title props were accepted and never read — four of the website's lint warnings. Dropped. The blog route was passing library="langgraph" for blog posts, which only ever looked harmless because the value was discarded. /docs keeps its landing-page treatment; it is the front door, not a page missing its shell. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/blog/[slug]/page.tsx | 8 +- .../docs/[library]/[section]/[slug]/page.tsx | 8 +- .../src/app/docs/choosing-an-adapter/page.tsx | 101 ++++----------- .../components/docs/DocsControlPlane.spec.tsx | 39 ++++++ .../src/components/docs/DocsControlPlane.tsx | 20 +-- .../src/components/docs/DocsSidebar.tsx | 119 ++++++++++++------ .../src/components/docs/MdxRenderer.tsx | 7 +- .../src/components/shared/Nav.spec.tsx | 26 +++- apps/website/src/components/shared/Nav.tsx | 20 ++- apps/website/src/styles/docs.css | 5 + 10 files changed, 203 insertions(+), 150 deletions(-) diff --git a/apps/website/src/app/blog/[slug]/page.tsx b/apps/website/src/app/blog/[slug]/page.tsx index be00ccd02..becb59648 100644 --- a/apps/website/src/app/blog/[slug]/page.tsx +++ b/apps/website/src/app/blog/[slug]/page.tsx @@ -110,13 +110,7 @@ export default async function BlogPostPage({ params }: Params) { ) : null} - +
diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index ae6bdac44..5bc411490 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -107,13 +107,7 @@ export default async function DocsPage({ params }: DocsRouteProps) { />
- +
{section === 'api' && (() => { const entries = loadApiDocs(library); diff --git a/apps/website/src/app/docs/choosing-an-adapter/page.tsx b/apps/website/src/app/docs/choosing-an-adapter/page.tsx index ee0b161d2..08f62b3d0 100644 --- a/apps/website/src/app/docs/choosing-an-adapter/page.tsx +++ b/apps/website/src/app/docs/choosing-an-adapter/page.tsx @@ -1,24 +1,14 @@ import fs from 'fs'; import path from 'path'; import { notFound } from 'next/navigation'; -import { MDXRemote } from 'next-mdx-remote/rsc'; -import rehypePrettyCode from 'rehype-pretty-code'; -import rehypeSlug from 'rehype-slug'; -import remarkGfm from 'remark-gfm'; -import { tokens } from '@threadplane/design-tokens'; -import { Container } from '../../../components/ui/Container'; -import { Section } from '../../../components/ui/Section'; -import { Eyebrow } from '../../../components/ui/Eyebrow'; -import { Callout } from '../../../components/docs/mdx/Callout'; -import { Steps, Step } from '../../../components/docs/mdx/Steps'; -import { Tabs, Tab } from '../../../components/docs/mdx/Tabs'; -import { Card, CardGroup } from '../../../components/docs/mdx/Card'; -import { CodeGroup } from '../../../components/docs/mdx/CodeGroup'; -import { Pre } from '../../../components/docs/mdx/CodeBlock'; -import { mdxHeadingComponents } from '../../../components/docs/mdx/headings'; +import { DocsControlPlane } from '../../../components/docs/DocsControlPlane'; +import { DocsSearch } from '../../../components/docs/DocsSearch'; +import { MdxRenderer } from '../../../components/docs/MdxRenderer'; import { createPageMetadata } from '../../../lib/site-metadata'; import { stripFrontmatter } from '../../../lib/docs'; +const PAGE_TITLE = 'Choosing an adapter'; + export const metadata = createPageMetadata({ title: 'Choosing an adapter — Threadplane', description: 'Decide between @threadplane/langgraph and @threadplane/ag-ui.', @@ -26,29 +16,6 @@ export const metadata = createPageMetadata({ type: 'website', }); -const mdxComponents = { - Callout, - Steps, - Step, - Tabs, - Tab, - Card, - CardGroup, - CodeGroup, - pre: Pre, - table: ({ children, ...rest }: React.HTMLAttributes) => ( -
- {children}
-
- ), - ...mdxHeadingComponents, -}; - -const rehypeOptions = { - theme: 'tokyo-night', - keepBackground: true, -}; - function resolveContentFile(): string | null { const candidates = [ path.join(process.cwd(), 'apps', 'website', 'content', 'docs', 'choosing-an-adapter', 'index.mdx'), @@ -64,49 +31,29 @@ export default function ChoosingAnAdapterPage() { const filePath = resolveContentFile(); if (!filePath) notFound(); - const raw = fs.readFileSync(filePath, 'utf8'); - const source = stripFrontmatter(raw); + const source = stripFrontmatter(fs.readFileSync(filePath, 'utf8')); return ( - <> -
- -
- - Documentation - -
-
- -
- -
- +
+ + {/* 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}
@@ -118,11 +122,13 @@ export function DocsContextContent({ export function DocsControlPlane(props: DocsControlPlaneProps) { const identity = { - library: props.activeLibrary, + library: props.activeLibrary ?? '', section: props.activeSection, slug: props.activeSlug, }; - const currentPath = `/docs/${props.activeLibrary}/${props.activeSection}/${props.activeSlug}`; + const currentPath = props.activeLibrary + ? `/docs/${props.activeLibrary}/${props.activeSection}/${props.activeSlug}` + : '/docs'; return (
diff --git a/apps/website/src/components/docs/DocsSidebar.tsx b/apps/website/src/components/docs/DocsSidebar.tsx index d85d8c406..3e7fa2b14 100644 --- a/apps/website/src/components/docs/DocsSidebar.tsx +++ b/apps/website/src/components/docs/DocsSidebar.tsx @@ -33,7 +33,8 @@ import { import { LibraryMark } from './LibraryMark'; export interface DocsNavigationProps { - activeLibrary: LibraryId; + /** `null` on a library-neutral docs page. */ + activeLibrary: LibraryId | null; activeSection: string; activeSlug: string; expanded?: Record; @@ -62,7 +63,7 @@ function LibraryDropdown({ activeLibrary, onNavigate, }: { - activeLibrary: LibraryId; + activeLibrary: LibraryId | null; onNavigate?: () => void; }) { const [open, setOpen] = useState(false); @@ -74,7 +75,8 @@ function LibraryDropdown({ useEffect(() => { const handler = (event: MouseEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); + if (ref.current && !ref.current.contains(event.target as Node)) + setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); @@ -82,7 +84,8 @@ function LibraryDropdown({ useEffect(() => { if (!open) return; - const items = menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR); + const items = + menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR); items?.[initialFocusRef.current]?.focus(); }, [open]); @@ -98,7 +101,10 @@ function LibraryDropdown({ const trigger = triggerRef.current; const menu = menuRef.current; if (!trigger || !menu) return; - const available = window.innerHeight - trigger.getBoundingClientRect().bottom - MENU_VIEWPORT_GUTTER; + const available = + window.innerHeight - + trigger.getBoundingClientRect().bottom - + MENU_VIEWPORT_GUTTER; menu.style.maxHeight = `${Math.max(MENU_MIN_HEIGHT, available)}px`; }; resize(); @@ -118,19 +124,22 @@ function LibraryDropdown({ const onMenuKeyDown = (event: KeyboardEvent) => { const items = Array.from( - menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR) ?? [], + menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR) ?? [] + ); + const current = Math.max( + 0, + items.indexOf(document.activeElement as HTMLElement) ); - const current = Math.max(0, items.indexOf(document.activeElement as HTMLElement)); const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' - ? items.length - 1 - : event.key === 'ArrowDown' - ? (current + 1) % items.length - : event.key === 'ArrowUp' - ? (current - 1 + items.length) % items.length - : -1; + ? items.length - 1 + : event.key === 'ArrowDown' + ? (current + 1) % items.length + : event.key === 'ArrowUp' + ? (current - 1 + items.length) % items.length + : -1; if (nextIndex >= 0) { event.preventDefault(); items[nextIndex]?.focus(); @@ -144,7 +153,9 @@ function LibraryDropdown({ } }; - const currentLibrary = getLibraryConfig(activeLibrary); + const currentLibrary = activeLibrary + ? getLibraryConfig(activeLibrary) + : undefined; return (
@@ -167,12 +178,22 @@ function LibraryDropdown({ className="docs-sidebar-lib-trigger" > - - - {currentLibrary?.title ?? activeLibrary} + {activeLibrary ? ( + + ) : null} + + {currentLibrary?.title ?? 'Choose a library'} -