Skip to content

Commit 69c2d54

Browse files
bloveclaude
andauthored
feat(website): align the docs index with the control plane (#923)
/docs was the only /docs/* route without the control plane. That was a deliberate call in #920 — it is a designed landing page, and the prose column would flatten its card grids — but the inconsistency reads worse than that risk. It now renders the docs shell with activeLibrary={null}, reusing the library-neutral state from #920. The landing content sits inside docs-shell-body but outside the [slug] route's md:max-w-3xl article measure, so the grids keep their own width: verified still 2-up at 426px on desktop and single-column at 375px. The sidebar picker is kept even though the page's main content is itself a backend picker. #911 removed a duplication of exactly this shape, so to be explicit: that was two statements of the same fact, this is a statement plus a shortcut for a reader who already knows where they are going. Also renames the render library's display label to json-render. It is called that 85 times across docs content, on the marketing page, and on the /docs card; "Render" existed only in docsConfig, feeding the picker, breadcrumbs, structured data and search. With the control plane on this page both labels are on screen at once. The package stays @threadplane/render and the URL stays /docs/render/. Two drifts found while building this: - The page passed pageTitle="Overview" while Nav resolved the drawer's title independently and got "Documentation" — the same page named two ways by viewport width. Both now read a shared DOCS_INDEX_TITLE. - e2e asserted getByText('Render'), which passed on a substring of json-render and would now also pass on the sidebar. It asserts card titles instead, and covers the Chat card again. Every new guard was mutation-tested. The naming test initially passed against a reverted docsConfig: getAllByText is exact-match and the picker menu is closed on mount, so it only ever saw the index card. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b7ce508 commit 69c2d54

7 files changed

Lines changed: 200 additions & 8 deletions

File tree

apps/website/e2e/website.spec.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,20 @@ test('docs page renders sidebar and content', async ({ page }) => {
182182

183183
test('docs landing page shows library cards', async ({ page }) => {
184184
await page.goto('/docs');
185-
await expect(page.getByText('LangGraph').first()).toBeVisible();
186-
await expect(page.getByText('Render').first()).toBeVisible();
187-
await expect(page.getByText('Chat').first()).toBeVisible();
188-
await expect(page.getByText('AG-UI').first()).toBeVisible();
185+
// Assert on card titles, not page text. A bare getByText('Render') passed on
186+
// a substring of "json-render"; hasText on the card would match the Chat
187+
// card too, whose blurb mentions json-render. Only the title is the card.
188+
const titles = page.locator('.docs-index-card-title');
189+
await expect(titles.filter({ hasText: /^LangGraph$/ })).toBeVisible();
190+
await expect(titles.filter({ hasText: /^json-render$/ })).toBeVisible();
191+
await expect(titles.filter({ hasText: /^AG-UI$/ })).toBeVisible();
192+
await expect(titles.filter({ hasText: /^Chat$/ })).toBeVisible();
193+
});
194+
195+
test('docs landing page carries the control plane', async ({ page }) => {
196+
await page.goto('/docs');
197+
await expect(page.getByRole('navigation', { name: 'Docs modes' })).toBeVisible();
198+
await expect(page.getByRole('button', { name: 'Choose a library' })).toBeVisible();
189199
});
190200

191201
test('api reference renders in docs', async ({ page }) => {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// @vitest-environment jsdom
2+
import React from 'react';
3+
import { fireEvent, render, screen, within } from '@testing-library/react';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
import DocsLandingPage from './page';
6+
7+
vi.mock('next/navigation', () => ({
8+
usePathname: () => '/docs',
9+
useRouter: () => ({ push: vi.fn() }),
10+
}));
11+
12+
beforeEach(() => {
13+
window.localStorage.clear();
14+
});
15+
16+
describe('docs index', () => {
17+
it('wears the same control plane as every other docs route', () => {
18+
render(<DocsLandingPage />);
19+
20+
const scope = screen.getByRole('heading', { name: 'Scope' }).closest('section');
21+
if (!scope) throw new Error('Expected a Scope section');
22+
// Library-neutral: the index is where you pick one, so it claims none.
23+
expect(within(scope).getByText('Docs')).toBeTruthy();
24+
expect(within(scope).getByText('Overview')).toBeTruthy();
25+
expect(screen.getByRole('button', { name: 'Choose a library' })).toBeTruthy();
26+
});
27+
28+
it('keeps the landing content out of the prose measure', () => {
29+
const { container } = render(<DocsLandingPage />);
30+
31+
// The card grids need their own width; the [slug] route's md:max-w-3xl
32+
// article measure would flatten them into a single column.
33+
const body = container.querySelector('.docs-index-body');
34+
expect(body).toBeTruthy();
35+
expect(body?.className).not.toContain('max-w-3xl');
36+
});
37+
38+
it('calls the render library what the docs call it', () => {
39+
render(<DocsLandingPage />);
40+
41+
// The picker menu is closed on mount, so its labels are not in the DOM
42+
// until it is opened. Asserting without this click passes even when
43+
// docsConfig still says "Render" — it only ever sees the index card.
44+
fireEvent.click(screen.getByRole('button', { name: 'Choose a library' }));
45+
const menu = screen.getByRole('menu');
46+
const pickerNames = within(menu)
47+
.getAllByRole('menuitemradio')
48+
.map((item) => item.querySelector('.docs-sidebar-lib-item-title')?.textContent);
49+
50+
// 85 occurrences of "json-render" across docs content vs one "Render" in
51+
// docsConfig. With the control plane on this page both labels are on
52+
// screen at once, so they have to agree.
53+
expect(pickerNames).toContain('json-render');
54+
expect(pickerNames).not.toContain('Render');
55+
});
56+
});

apps/website/src/app/docs/page.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { Eyebrow } from '../../components/ui/Eyebrow';
66
import { Card } from '../../components/ui/Card';
77
import { Pill } from '../../components/ui/Pill';
88
import { CopyButton } from '../../components/docs/CopyButton';
9+
import { DocsControlPlane } from '../../components/docs/DocsControlPlane';
10+
import { DocsSearch } from '../../components/docs/DocsSearch';
11+
import { DOCS_INDEX_TITLE } from '../../lib/docs-config';
912
import { createPageMetadata } from '../../lib/site-metadata';
1013

1114
export const metadata = createPageMetadata({
@@ -152,7 +155,19 @@ function GlyphChip({ size, children }: { size: number; children: ReactNode }) {
152155

153156
export default function DocsLandingPage() {
154157
return (
155-
<>
158+
<div className="flex min-h-screen docs-shell-page">
159+
<DocsSearch />
160+
{/* The index is library-neutral: it is where you pick one. */}
161+
<DocsControlPlane
162+
activeLibrary={null}
163+
activeSection=""
164+
activeSlug=""
165+
pageTitle={DOCS_INDEX_TITLE}
166+
/>
167+
{/* Deliberately outside the article measure the [slug] route uses — the
168+
* card grids need their own width, and the prose column would flatten
169+
* them. The shell supplies the chrome, not the content width. */}
170+
<div className="flex-1 min-w-0 docs-shell-body docs-index-body">
156171

157172
{/* Hero */}
158173
<Section surface="canvas" ariaLabelledBy="docs-heading">
@@ -309,6 +324,7 @@ export default function DocsLandingPage() {
309324
</div>
310325
</Container>
311326
</Section>
312-
</>
327+
</div>
328+
</div>
313329
);
314330
}

apps/website/src/components/shared/Nav.spec.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ describe('Docs mobile navigation', () => {
2626
pathnameRef.current = '/docs/langgraph/guides/streaming';
2727
});
2828

29+
it('names the docs index the same way the page does', () => {
30+
pathnameRef.current = '/docs';
31+
render(<Nav />);
32+
fireEvent.click(screen.getByRole('button', { name: 'Open menu' }));
33+
const dialog = screen.getByRole('dialog', { name: 'Mobile navigation' });
34+
35+
// The page passes pageTitle="Overview"; Nav derives its own title. When
36+
// they drift, the same page is called two different things depending on
37+
// viewport width.
38+
const scope = within(dialog).getByRole('heading', { name: 'Scope' }).closest('section');
39+
if (!scope) throw new Error('Expected a Scope section');
40+
expect(within(scope).getByText('Overview')).toBeTruthy();
41+
expect(within(scope).queryByText('Documentation')).toBeNull();
42+
});
43+
2944
it('does not invent a library on a library-neutral docs page', () => {
3045
pathnameRef.current = '/docs/choosing-an-adapter';
3146
render(<Nav />);

apps/website/src/components/shared/Nav.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
33
import Link from 'next/link';
44
import { usePathname } from 'next/navigation';
55
import {
6+
DOCS_INDEX_TITLE,
67
findDocsPage,
78
getLibraryConfig,
89
specialDocsPages,
@@ -108,7 +109,7 @@ export function Nav() {
108109
const docsPageTitle =
109110
findDocsPage(activeLibrary, activeSection, activeSlug)?.title ??
110111
specialDocsPage?.title ??
111-
'Documentation';
112+
(pathname === '/docs' ? DOCS_INDEX_TITLE : 'Documentation');
112113
const navRef = useRef<HTMLElement>(null);
113114
const mobileTriggerRef = useRef<HTMLButtonElement>(null);
114115
const mobileDialogRef = useRef<HTMLDivElement>(null);

apps/website/src/lib/docs-config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export interface SpecialDocsPage {
6262
description: string;
6363
}
6464

65+
/**
66+
* What the docs root calls itself in the control plane's Scope card. Shared so
67+
* the page and the mobile drawer, which resolve the title independently,
68+
* cannot drift into naming the same page two different things.
69+
*/
70+
export const DOCS_INDEX_TITLE = 'Overview';
71+
6572
export const specialDocsPages: SpecialDocsPage[] = [
6673
{
6774
path: '/docs/choosing-an-adapter',
@@ -133,7 +140,9 @@ export const docsConfig: DocsLibrary[] = [
133140
},
134141
{
135142
id: 'render',
136-
title: 'Render',
143+
// Display label only. The package is @threadplane/render and the docs URL
144+
// stays /docs/render/ — but every page of prose calls it json-render.
145+
title: 'json-render',
137146
description: 'Declarative UI rendering from JSON specifications',
138147
group: 'library',
139148
sections: [
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Aligning the docs index with the control plane
2+
3+
**Date:** 2026-09-01
4+
**Scope:** `/docs`, and the `Render` / `json-render` naming split
5+
**Status:** approved, ready to implement
6+
7+
## Context
8+
9+
After #920, `/docs` is the only `/docs/*` route without the control plane. That
10+
was a deliberate call at the time — it is a designed landing page, and forcing
11+
its 2-up card grids into the prose column would flatten it. In review the
12+
inconsistency was judged worse than that risk, so the page adopts the shell.
13+
14+
## Design
15+
16+
### The shell, without the prose measure
17+
18+
`/docs` renders `docs-shell-page` with `<DocsControlPlane activeLibrary={null}>`,
19+
reusing the library-neutral state added in #920.
20+
21+
The landing content goes inside `docs-shell-body` but **not** inside the
22+
`md:max-w-3xl` article measure the `[slug]` route uses. Its `Section`/`Container`
23+
structure keeps its own width. The chrome becomes consistent; the layout does
24+
not get squeezed.
25+
26+
Scope reads `Docs / Overview`. Passing the page title verbatim would render
27+
`Docs / Documentation`, which is redundant.
28+
29+
`Nav` resolves the drawer's title independently of the page, so setting this on
30+
the page alone made the desktop say `Docs / Overview` while the mobile drawer
31+
said `Docs / Documentation` — the same page named two ways by viewport width.
32+
Both now read a shared `DOCS_INDEX_TITLE` constant so they cannot drift.
33+
34+
### The picker stays, deliberately
35+
36+
The page's main content *is* a backend picker, so the sidebar picker is
37+
arguably duplicative — the same class of problem removed in #911, where the
38+
library was stated twice.
39+
40+
Kept anyway, because the two do different jobs: the cards are a decision aid
41+
(compare, copy the install line, follow the quickstart), the picker is a
42+
shortcut for a returning reader who already knows where they are going. The
43+
#911 duplication was two *statements of the same fact*; this is a statement and
44+
a shortcut.
45+
46+
### `Render``json-render`
47+
48+
The library is called `json-render` 85 times across docs content and on the
49+
marketing page, and on the `/docs` card. It is called `Render` in exactly one
50+
place: `docsConfig[].title`, which feeds the picker, breadcrumbs, structured
51+
data and search.
52+
53+
Once the index has the control plane, both names appear on screen at once — the
54+
sidebar saying `Render`, the card saying `json-render`.
55+
56+
`docsConfig` title becomes `json-render`. The package stays
57+
`@threadplane/render` and the URL stays `/docs/render/`; only the display label
58+
changes. Marketing surfaces (`/render`, the footer, `solutions-data`) keep
59+
`Render` — those describe the product page, a different context, and are not
60+
part of this alignment.
61+
62+
### Test hygiene
63+
64+
`e2e/website.spec.ts``'docs landing page shows library cards'` asserts
65+
`getByText('Render')`, which passes on a substring of `json-render` and would
66+
also pass on the new sidebar. It is tightened to assert the cards themselves.
67+
68+
## Testing
69+
70+
1. **The index renders the control plane** with a library-neutral Scope of
71+
`Docs / Overview` and a `Choose a library` picker.
72+
2. **The picker reads `json-render`**, not `Render`.
73+
3. **The landing content keeps its width** — it is not inside the article
74+
measure.
75+
4. **The drawer and the page agree on the index's name.**
76+
77+
Each guard was mutation-tested. The naming test initially passed against a
78+
reverted `docsConfig``getAllByText` is exact-match and the picker menu is
79+
closed on mount, so it only ever saw the index card. It now opens the menu
80+
first.
81+
82+
## Out of scope
83+
84+
Marketing surfaces keep `Render`. Renaming those is a product-vocabulary
85+
decision about the `/render` page, not about docs consistency.

0 commit comments

Comments
 (0)