Skip to content

Commit d99daae

Browse files
bloveclaude
andauthored
fix(website): give library-neutral docs pages an honest control plane (#920)
* 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 <noreply@anthropic.com> * fix(website): give library-neutral docs pages an honest control plane 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 / <page>" 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent bd868bf commit d99daae

11 files changed

Lines changed: 342 additions & 150 deletions

File tree

apps/website/src/app/blog/[slug]/page.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,7 @@ export default async function BlogPostPage({ params }: Params) {
110110
<TagChips tags={post.frontmatter.tags} />
111111
) : null}
112112
</header>
113-
<MdxRenderer
114-
source={post.content}
115-
library="langgraph"
116-
section="blog"
117-
slug={post.slug}
118-
title={post.frontmatter.title}
119-
/>
113+
<MdxRenderer source={post.content} />
120114
</article>
121115
<DocsTOC headings={headings} />
122116
</div>

apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
107107
/>
108108
</div>
109109
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
110-
<MdxRenderer
111-
source={doc.body}
112-
library={library as LibraryId}
113-
section={section}
114-
slug={slug}
115-
title={doc.title}
116-
/>
110+
<MdxRenderer source={doc.body} />
117111
</article>
118112
{section === 'api' && (() => {
119113
const entries = loadApiDocs(library);
Lines changed: 24 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,21 @@
11
import fs from 'fs';
22
import path from 'path';
33
import { notFound } from 'next/navigation';
4-
import { MDXRemote } from 'next-mdx-remote/rsc';
5-
import rehypePrettyCode from 'rehype-pretty-code';
6-
import rehypeSlug from 'rehype-slug';
7-
import remarkGfm from 'remark-gfm';
8-
import { tokens } from '@threadplane/design-tokens';
9-
import { Container } from '../../../components/ui/Container';
10-
import { Section } from '../../../components/ui/Section';
11-
import { Eyebrow } from '../../../components/ui/Eyebrow';
12-
import { Callout } from '../../../components/docs/mdx/Callout';
13-
import { Steps, Step } from '../../../components/docs/mdx/Steps';
14-
import { Tabs, Tab } from '../../../components/docs/mdx/Tabs';
15-
import { Card, CardGroup } from '../../../components/docs/mdx/Card';
16-
import { CodeGroup } from '../../../components/docs/mdx/CodeGroup';
17-
import { Pre } from '../../../components/docs/mdx/CodeBlock';
18-
import { mdxHeadingComponents } from '../../../components/docs/mdx/headings';
4+
import { DocsControlPlane } from '../../../components/docs/DocsControlPlane';
5+
import { DocsSearch } from '../../../components/docs/DocsSearch';
6+
import { MdxRenderer } from '../../../components/docs/MdxRenderer';
197
import { createPageMetadata } from '../../../lib/site-metadata';
208
import { stripFrontmatter } from '../../../lib/docs';
219

10+
const PAGE_TITLE = 'Choosing an adapter';
11+
2212
export const metadata = createPageMetadata({
2313
title: 'Choosing an adapter — Threadplane',
2414
description: 'Decide between @threadplane/langgraph and @threadplane/ag-ui.',
2515
pathname: '/docs/choosing-an-adapter',
2616
type: 'website',
2717
});
2818

29-
const mdxComponents = {
30-
Callout,
31-
Steps,
32-
Step,
33-
Tabs,
34-
Tab,
35-
Card,
36-
CardGroup,
37-
CodeGroup,
38-
pre: Pre,
39-
table: ({ children, ...rest }: React.HTMLAttributes<HTMLTableElement>) => (
40-
<div className="docs-table-scroll">
41-
<table {...rest}>{children}</table>
42-
</div>
43-
),
44-
...mdxHeadingComponents,
45-
};
46-
47-
const rehypeOptions = {
48-
theme: 'tokyo-night',
49-
keepBackground: true,
50-
};
51-
5219
function resolveContentFile(): string | null {
5320
const candidates = [
5421
path.join(process.cwd(), 'apps', 'website', 'content', 'docs', 'choosing-an-adapter', 'index.mdx'),
@@ -64,49 +31,29 @@ export default function ChoosingAnAdapterPage() {
6431
const filePath = resolveContentFile();
6532
if (!filePath) notFound();
6633

67-
const raw = fs.readFileSync(filePath, 'utf8');
68-
const source = stripFrontmatter(raw);
34+
const source = stripFrontmatter(fs.readFileSync(filePath, 'utf8'));
6935

7036
return (
71-
<>
72-
<Section surface="canvas" ariaLabelledBy="choosing-an-adapter-heading">
73-
<Container>
74-
<div className="adapter-hero-inner">
75-
<Eyebrow tone="accent" className="adapter-eyebrow-spaced">
76-
Documentation
77-
</Eyebrow>
78-
<div id="choosing-an-adapter-heading" />
79-
</div>
80-
</Container>
81-
</Section>
82-
83-
<Section surface="canvas">
84-
<Container>
37+
<div className="flex min-h-screen docs-shell-page">
38+
<DocsSearch />
39+
{/* This page is deliberately library-neutral: it is the page that helps
40+
* you pick one, so the picker opens with nothing selected. */}
41+
<DocsControlPlane
42+
activeLibrary={null}
43+
activeSection=""
44+
activeSlug=""
45+
pageTitle={PAGE_TITLE}
46+
/>
47+
<div className="flex-1 flex min-w-0 docs-shell-body">
48+
<div className="flex-1 min-w-0">
8549
<article
86-
className="docs-prose prose prose-slate max-w-none adapter-article"
87-
style={
88-
{
89-
'--tw-prose-body': tokens.colors.textSecondary,
90-
'--tw-prose-headings': tokens.colors.textPrimary,
91-
'--tw-prose-code': tokens.colors.accent,
92-
'--tw-prose-links': tokens.colors.accent,
93-
} as React.CSSProperties
94-
}
50+
aria-label={PAGE_TITLE}
51+
className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden"
9552
>
96-
<MDXRemote
97-
source={source}
98-
components={mdxComponents}
99-
options={{
100-
mdxOptions: {
101-
remarkPlugins: [remarkGfm],
102-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
103-
rehypePlugins: [rehypeSlug, [rehypePrettyCode, rehypeOptions] as any],
104-
},
105-
}}
106-
/>
53+
<MdxRenderer source={source} />
10754
</article>
108-
</Container>
109-
</Section>
110-
</>
55+
</div>
56+
</div>
57+
</div>
11158
);
11259
}

apps/website/src/components/docs/DocsControlPlane.spec.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,45 @@ describe('DocsControlPlane', () => {
232232
});
233233
});
234234

235+
describe('DocsControlPlane — library-neutral', () => {
236+
it('states only what it knows in Scope', () => {
237+
render(
238+
<DocsControlPlane
239+
activeLibrary={null}
240+
activeSection=""
241+
activeSlug=""
242+
pageTitle="Choosing an adapter"
243+
/>,
244+
);
245+
246+
const scope = screen.getByRole('heading', { name: 'Scope' }).closest('section');
247+
if (!scope) throw new Error('Expected Scope section');
248+
expect(within(scope).getByText('Choosing an adapter')).toBeTruthy();
249+
expect(within(scope).queryByText('LangGraph')).toBeNull();
250+
expect(within(scope).queryByText('Getting Started')).toBeNull();
251+
});
252+
253+
it('offers an unselected picker and no section tree', () => {
254+
render(
255+
<DocsControlPlane
256+
activeLibrary={null}
257+
activeSection=""
258+
activeSlug=""
259+
pageTitle="Choosing an adapter"
260+
/>,
261+
);
262+
263+
const trigger = screen.getByRole('button', { name: 'Choose a library' });
264+
fireEvent.click(trigger);
265+
const items = screen.getAllByRole('menuitemradio');
266+
expect(items.length).toBeGreaterThan(0);
267+
expect(items.every((i) => i.getAttribute('aria-checked') === 'false')).toBe(true);
268+
269+
// No library means there is no section tree to show.
270+
expect(screen.queryByRole('button', { name: 'Getting Started' })).toBeNull();
271+
});
272+
});
273+
235274
describe('DocsContextContent', () => {
236275
it('reuses the same sentence-case navigation content for mobile', () => {
237276
render(

apps/website/src/components/docs/DocsControlPlane.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import { buildCockpitModeHref } from '../../lib/cockpit-links';
2828
import { DocsNavigation } from './DocsSidebar';
2929

3030
export interface DocsControlPlaneProps {
31-
activeLibrary: LibraryId;
31+
/** `null` on a library-neutral docs page, e.g. /docs/choosing-an-adapter. */
32+
activeLibrary: LibraryId | null;
3233
activeSection: string;
3334
activeSlug: string;
3435
pageTitle: string;
@@ -46,8 +47,8 @@ export function DocsContextContent({
4647
onNavigate,
4748
}: DocsControlPlaneProps & { mobile?: boolean; onNavigate?: () => void }) {
4849
const preferences = useControlPlanePreferences('docs');
49-
const library = getLibraryConfig(activeLibrary);
50-
const section = getDocsSection(activeLibrary, activeSection);
50+
const library = activeLibrary ? getLibraryConfig(activeLibrary) : undefined;
51+
const section = activeLibrary ? getDocsSection(activeLibrary, activeSection) : undefined;
5152
const openSearch = () => {
5253
if (!mobile) {
5354
dispatchSearch();
@@ -65,8 +66,11 @@ export function DocsContextContent({
6566
<div data-docs-control-plane-context data-mobile={mobile || undefined}>
6667
<ControlPlaneSection title="Scope" collapsible={false}>
6768
<div className="docs-control-plane-scope">
68-
<span>{library?.title ?? activeLibrary}</span>
69-
<span>{section?.title ?? activeSection}</span>
69+
{/* A neutral page has no library and no section. Say only what is
70+
* true — inventing them is how the mobile drawer came to claim
71+
* "LangGraph / Getting Started" on the adapter-comparison page. */}
72+
<span>{library?.title ?? 'Docs'}</span>
73+
{library && section ? <span>{section.title}</span> : null}
7074
<strong>{pageTitle}</strong>
7175
</div>
7276
</ControlPlaneSection>
@@ -118,11 +122,13 @@ export function DocsContextContent({
118122

119123
export function DocsControlPlane(props: DocsControlPlaneProps) {
120124
const identity = {
121-
library: props.activeLibrary,
125+
library: props.activeLibrary ?? '',
122126
section: props.activeSection,
123127
slug: props.activeSlug,
124128
};
125-
const currentPath = `/docs/${props.activeLibrary}/${props.activeSection}/${props.activeSlug}`;
129+
const currentPath = props.activeLibrary
130+
? `/docs/${props.activeLibrary}/${props.activeSection}/${props.activeSlug}`
131+
: '/docs';
126132

127133
return (
128134
<div className="docs-control-plane" data-docs-control-plane>

0 commit comments

Comments
 (0)